Autosave: 20260628-021058
This commit is contained in:
parent
7a3d2b5d5f
commit
aebf8e3d39
@ -67,6 +67,10 @@ const config = {
|
||||
|
||||
|
||||
gpt_key: process.env.GPT_KEY || '',
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
},
|
||||
};
|
||||
|
||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||
|
||||
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -20,6 +19,7 @@ const pexelsRoutes = require('./routes/pexels');
|
||||
const organizationForAuthRoutes = require('./routes/organizationLogin');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
const stripeRoutes = require('./routes/stripe');
|
||||
|
||||
|
||||
|
||||
@ -135,6 +135,8 @@ app.use('/api-docs', function (req, res, next) {
|
||||
app.use(cors({origin: true}));
|
||||
require('./auth/auth');
|
||||
|
||||
app.use('/api/stripe/webhook', stripeRoutes.webhookRouter);
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
@ -205,6 +207,8 @@ app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit
|
||||
|
||||
app.use('/api/daily_snapshots', passport.authenticate('jwt', {session: false}), daily_snapshotsRoutes);
|
||||
|
||||
app.use('/api/stripe', passport.authenticate('jwt', {session: false}), stripeRoutes.checkoutRouter);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
@ -3,9 +3,9 @@ const express = require('express');
|
||||
|
||||
const Ota_connectionsService = require('../services/ota_connections');
|
||||
const Ota_connectionsDBApi = require('../db/api/ota_connections');
|
||||
const OtaIntegrationService = require('../services/otaIntegration');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
@ -132,6 +132,29 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/sync-all', wrapAsync(async (req, res) => {
|
||||
const syncPayload = req.body.data || req.body || {};
|
||||
const payload = await OtaIntegrationService.syncAll(syncPayload, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.put('/:id/sync', wrapAsync(async (req, res) => {
|
||||
const syncPayload = req.body.data || req.body || {};
|
||||
const payload = await OtaIntegrationService.syncConnection(req.params.id, syncPayload, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/:id/import-reservations', wrapAsync(async (req, res) => {
|
||||
const syncPayload = { ...(req.body.data || req.body || {}), mode: 'import' };
|
||||
const payload = await OtaIntegrationService.syncConnection(req.params.id, syncPayload, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.get('/:id/export', wrapAsync(async (req, res) => {
|
||||
const payload = await OtaIntegrationService.exportConnection(req.params.id, req.query || {}, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ota_connections/{id}:
|
||||
|
||||
289
backend/src/routes/stripe.js
Normal file
289
backend/src/routes/stripe.js
Normal file
@ -0,0 +1,289 @@
|
||||
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,
|
||||
};
|
||||
772
backend/src/services/otaIntegration.js
Normal file
772
backend/src/services/otaIntegration.js
Normal file
@ -0,0 +1,772 @@
|
||||
const db = require('../db/models');
|
||||
|
||||
const { Op } = db.Sequelize;
|
||||
|
||||
const PROVIDER_LABELS = {
|
||||
booking_com: 'Booking.com',
|
||||
expedia: 'Expedia',
|
||||
airbnb: 'Airbnb',
|
||||
agoda: 'Agoda',
|
||||
other: 'Other OTA',
|
||||
};
|
||||
|
||||
const RESERVATION_STATUSES = [
|
||||
'pending',
|
||||
'confirmed',
|
||||
'checked_in',
|
||||
'checked_out',
|
||||
'cancelled',
|
||||
'no_show',
|
||||
];
|
||||
|
||||
const isUuid = (value) =>
|
||||
typeof value === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
|
||||
const createHttpError = (message, code = 400) => {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
const stringValue = (value) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const numberValue = (value, fallback = 0) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const dateValue = (value) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
};
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const eachDate = (startDate, days) =>
|
||||
Array.from({ length: days }, (_, index) => addDays(startDate, index).toISOString().slice(0, 10));
|
||||
|
||||
const getPath = (source, path) => {
|
||||
if (!source || !path) return undefined;
|
||||
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current === undefined || current === null) return undefined;
|
||||
return current[key];
|
||||
}, source);
|
||||
};
|
||||
|
||||
const firstDefined = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
const value = getPath(source, path);
|
||||
if (value !== undefined && value !== null && value !== '') return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const splitName = (rawName) => {
|
||||
const name = stringValue(rawName);
|
||||
if (!name) return { firstName: 'OTA', lastName: 'Guest' };
|
||||
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: 'Guest' };
|
||||
|
||||
return {
|
||||
firstName: parts.slice(0, -1).join(' '),
|
||||
lastName: parts.slice(-1).join(' '),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeProviderLabel = (provider) => PROVIDER_LABELS[provider] || 'OTA';
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
const status = stringValue(value)?.toLowerCase().replace(/[\s-]+/g, '_');
|
||||
|
||||
if (!status) return 'confirmed';
|
||||
if (RESERVATION_STATUSES.includes(status)) return status;
|
||||
|
||||
if (['booked', 'booking', 'accepted', 'active', 'reserved', 'commit'].includes(status)) {
|
||||
return 'confirmed';
|
||||
}
|
||||
|
||||
if (['modified', 'amended', 'updated'].includes(status)) return 'confirmed';
|
||||
if (['canceled', 'cancelled_by_guest', 'cancelled_by_host', 'void'].includes(status)) return 'cancelled';
|
||||
if (['noshow', 'no_showed'].includes(status)) return 'no_show';
|
||||
|
||||
return 'confirmed';
|
||||
};
|
||||
|
||||
const normalizeExternalReservation = (rawReservation, connection) => {
|
||||
const externalReservationId = stringValue(
|
||||
firstDefined(rawReservation, [
|
||||
'externalReservationId',
|
||||
'external_reservation_id',
|
||||
'reservationId',
|
||||
'reservation_id',
|
||||
'confirmationCode',
|
||||
'confirmation_code',
|
||||
'confirmation',
|
||||
'itineraryId',
|
||||
'itinerary_id',
|
||||
'bookingId',
|
||||
'booking_id',
|
||||
'id',
|
||||
]),
|
||||
);
|
||||
|
||||
if (!externalReservationId) {
|
||||
throw createHttpError('OTA reservation import failed: external reservation id is required.');
|
||||
}
|
||||
|
||||
const checkIn = dateValue(
|
||||
firstDefined(rawReservation, [
|
||||
'checkIn',
|
||||
'check_in',
|
||||
'check_in_at',
|
||||
'arrivalDate',
|
||||
'arrival_date',
|
||||
'startDate',
|
||||
'start_date',
|
||||
'stayDateRange.start',
|
||||
'dates.start',
|
||||
'dates.start_date',
|
||||
]),
|
||||
);
|
||||
|
||||
const checkOut = dateValue(
|
||||
firstDefined(rawReservation, [
|
||||
'checkOut',
|
||||
'check_out',
|
||||
'check_out_at',
|
||||
'departureDate',
|
||||
'departure_date',
|
||||
'endDate',
|
||||
'end_date',
|
||||
'stayDateRange.end',
|
||||
'dates.end',
|
||||
'dates.end_date',
|
||||
]),
|
||||
);
|
||||
|
||||
if (!checkIn || !checkOut || checkOut <= checkIn) {
|
||||
throw createHttpError(
|
||||
`OTA reservation ${externalReservationId} import failed: valid check-in and check-out dates are required.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = firstDefined(rawReservation, [
|
||||
'guestName',
|
||||
'guest_name',
|
||||
'guest.name',
|
||||
'guest.fullName',
|
||||
'guest.full_name',
|
||||
'booker.name',
|
||||
'primaryGuest.name',
|
||||
]);
|
||||
const splitGuestName = splitName(fullName);
|
||||
|
||||
const firstName = stringValue(
|
||||
firstDefined(rawReservation, [
|
||||
'guestFirstName',
|
||||
'guest_first_name',
|
||||
'guest.firstName',
|
||||
'guest.first_name',
|
||||
'booker.firstName',
|
||||
'booker.first_name',
|
||||
'primaryGuest.firstName',
|
||||
'primaryGuest.first_name',
|
||||
]),
|
||||
) || splitGuestName.firstName;
|
||||
|
||||
const lastName = stringValue(
|
||||
firstDefined(rawReservation, [
|
||||
'guestLastName',
|
||||
'guest_last_name',
|
||||
'guest.lastName',
|
||||
'guest.last_name',
|
||||
'booker.lastName',
|
||||
'booker.last_name',
|
||||
'primaryGuest.lastName',
|
||||
'primaryGuest.last_name',
|
||||
]),
|
||||
) || splitGuestName.lastName;
|
||||
|
||||
return {
|
||||
externalReservationId,
|
||||
reservationNumber:
|
||||
stringValue(firstDefined(rawReservation, ['reservationNumber', 'reservation_number', 'code', 'reference'])) ||
|
||||
`${connection.provider || 'OTA'}-${externalReservationId}`,
|
||||
status: normalizeStatus(firstDefined(rawReservation, ['status', 'bookingStatus', 'booking_status', 'state'])),
|
||||
checkIn,
|
||||
checkOut,
|
||||
adults: Math.max(1, numberValue(firstDefined(rawReservation, ['adults', 'adultCount', 'adult_count', 'guestCounts.adults']), 1)),
|
||||
children: Math.max(0, numberValue(firstDefined(rawReservation, ['children', 'childCount', 'child_count', 'guestCounts.children']), 0)),
|
||||
totalAmount: numberValue(firstDefined(rawReservation, ['totalAmount', 'total_amount', 'amount', 'price', 'totalPrice.amount', 'pricing.total', 'financials.total']), 0),
|
||||
taxAmount: numberValue(firstDefined(rawReservation, ['taxAmount', 'tax_amount', 'tax', 'totalPrice.tax', 'pricing.tax']), 0),
|
||||
depositAmount: numberValue(firstDefined(rawReservation, ['depositAmount', 'deposit_amount', 'deposit', 'prepaidAmount']), 0),
|
||||
specialRequests: stringValue(firstDefined(rawReservation, ['specialRequests', 'special_requests', 'notes', 'guest.notes'])),
|
||||
cancellationReason: stringValue(firstDefined(rawReservation, ['cancellationReason', 'cancellation_reason', 'cancelReason'])),
|
||||
guest: {
|
||||
externalGuestId: stringValue(firstDefined(rawReservation, ['guest.id', 'guestId', 'guest_id', 'booker.id', 'primaryGuest.id'])),
|
||||
firstName,
|
||||
lastName,
|
||||
email: stringValue(firstDefined(rawReservation, ['guest.email', 'booker.email', 'primaryGuest.email', 'email'])),
|
||||
phone: stringValue(firstDefined(rawReservation, ['guest.phone', 'booker.phone', 'primaryGuest.phone', 'phone'])),
|
||||
country: stringValue(firstDefined(rawReservation, ['guest.country', 'booker.country', 'primaryGuest.country', 'country'])),
|
||||
},
|
||||
room: {
|
||||
id: stringValue(firstDefined(rawReservation, ['roomId', 'room_id', 'room.id'])),
|
||||
number: stringValue(firstDefined(rawReservation, ['roomNumber', 'room_number', 'room.number', 'unitName', 'unit_name', 'listingId', 'listing_id'])),
|
||||
},
|
||||
roomType: {
|
||||
id: stringValue(firstDefined(rawReservation, ['roomTypeId', 'room_type_id', 'roomType.id', 'room_type.id'])),
|
||||
name: stringValue(firstDefined(rawReservation, ['roomTypeName', 'room_type_name', 'roomType.name', 'room_type.name', 'unitTypeName'])),
|
||||
},
|
||||
ratePlanId: stringValue(firstDefined(rawReservation, ['ratePlanId', 'rate_plan_id', 'ratePlan.id', 'rate_plan.id'])),
|
||||
hotelId: stringValue(firstDefined(rawReservation, ['hotelId', 'hotel_id', 'hotel.id'])),
|
||||
raw: rawReservation,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeInboundReservations = (payload) => {
|
||||
if (!payload) return [];
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload.reservations)) return payload.reservations;
|
||||
if (Array.isArray(payload.bookings)) return payload.bookings;
|
||||
if (Array.isArray(payload.data)) return payload.data;
|
||||
if (payload.reservation) return [payload.reservation];
|
||||
if (payload.booking) return [payload.booking];
|
||||
return [];
|
||||
};
|
||||
|
||||
const currentOrganizationId = (currentUser, connection) =>
|
||||
currentUser?.organizationsId ||
|
||||
currentUser?.organizationId ||
|
||||
currentUser?.organizations?.id ||
|
||||
connection.organizationsId ||
|
||||
null;
|
||||
|
||||
const scopedWhere = (baseWhere, connection, currentUser) => {
|
||||
const where = { ...baseWhere };
|
||||
const organizationId = currentOrganizationId(currentUser, connection);
|
||||
|
||||
if (connection.hotelId) where.hotelId = connection.hotelId;
|
||||
if (organizationId) where.organizationsId = organizationId;
|
||||
|
||||
return where;
|
||||
};
|
||||
|
||||
const getConnection = async (connectionId, transaction) => {
|
||||
if (!isUuid(connectionId)) throw createHttpError('Valid OTA connection id is required.');
|
||||
|
||||
const connection = await db.ota_connections.findByPk(connectionId, { transaction });
|
||||
|
||||
if (!connection) throw createHttpError('OTA connection was not found.', 404);
|
||||
|
||||
return connection;
|
||||
};
|
||||
|
||||
const ensureSalesChannel = async (connection, currentUser, transaction) => {
|
||||
if (connection.sales_channelId) {
|
||||
const existingChannel = await db.sales_channels.findByPk(connection.sales_channelId, { transaction });
|
||||
if (existingChannel) return existingChannel;
|
||||
}
|
||||
|
||||
const providerName = normalizeProviderLabel(connection.provider);
|
||||
const externalReference = `ota:${connection.provider || 'other'}:${connection.account_identifier || connection.id}`;
|
||||
const organizationId = currentOrganizationId(currentUser, connection);
|
||||
const where = { external_reference: externalReference };
|
||||
|
||||
if (organizationId) where.organizationsId = organizationId;
|
||||
|
||||
let channel = await db.sales_channels.findOne({ where, transaction });
|
||||
|
||||
if (!channel) {
|
||||
channel = await db.sales_channels.create(
|
||||
{
|
||||
channel_type: 'ota',
|
||||
name: providerName,
|
||||
external_reference: externalReference,
|
||||
active: true,
|
||||
hotelId: connection.hotelId || null,
|
||||
organizationsId: organizationId,
|
||||
createdById: currentUser?.id || null,
|
||||
updatedById: currentUser?.id || null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
await connection.update({ sales_channelId: channel.id, updatedById: currentUser?.id || null }, { transaction });
|
||||
|
||||
return channel;
|
||||
};
|
||||
|
||||
const ensureGuest = async (reservation, connection, currentUser, transaction) => {
|
||||
const organizationId = currentOrganizationId(currentUser, connection);
|
||||
const guestImportHash = `ota-guest:${connection.id}:${reservation.guest.externalGuestId || reservation.guest.email || reservation.externalReservationId}`;
|
||||
const guestWhere = reservation.guest.email
|
||||
? {
|
||||
[Op.or]: [{ importHash: guestImportHash }, { email: reservation.guest.email }],
|
||||
}
|
||||
: { importHash: guestImportHash };
|
||||
|
||||
let guest = await db.guests.findOne({ where: guestWhere, transaction });
|
||||
const guestPayload = {
|
||||
first_name: reservation.guest.firstName,
|
||||
last_name: reservation.guest.lastName,
|
||||
email: reservation.guest.email,
|
||||
phone: reservation.guest.phone,
|
||||
country: reservation.guest.country,
|
||||
importHash: guestImportHash,
|
||||
hotelId: reservation.hotelId || connection.hotelId || null,
|
||||
organizationsId: organizationId,
|
||||
updatedById: currentUser?.id || null,
|
||||
};
|
||||
|
||||
if (guest) {
|
||||
await guest.update(guestPayload, { transaction });
|
||||
return guest;
|
||||
}
|
||||
|
||||
guest = await db.guests.create(
|
||||
{
|
||||
...guestPayload,
|
||||
createdById: currentUser?.id || null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
return guest;
|
||||
};
|
||||
|
||||
const findRoomType = async (reservation, connection, currentUser, transaction) => {
|
||||
if (isUuid(reservation.roomType.id)) {
|
||||
const roomType = await db.room_types.findByPk(reservation.roomType.id, { transaction });
|
||||
if (roomType) return roomType;
|
||||
}
|
||||
|
||||
if (!reservation.roomType.name) return null;
|
||||
|
||||
return db.room_types.findOne({
|
||||
where: scopedWhere(
|
||||
{
|
||||
name: { [Op.iLike]: reservation.roomType.name },
|
||||
},
|
||||
connection,
|
||||
currentUser,
|
||||
),
|
||||
transaction,
|
||||
});
|
||||
};
|
||||
|
||||
const findExplicitRoom = async (reservation, connection, currentUser, transaction) => {
|
||||
if (isUuid(reservation.room.id)) {
|
||||
const room = await db.rooms.findByPk(reservation.room.id, { transaction });
|
||||
if (room) return room;
|
||||
}
|
||||
|
||||
if (!reservation.room.number) return null;
|
||||
|
||||
return db.rooms.findOne({
|
||||
where: scopedWhere(
|
||||
{
|
||||
room_number: reservation.room.number,
|
||||
},
|
||||
connection,
|
||||
currentUser,
|
||||
),
|
||||
transaction,
|
||||
});
|
||||
};
|
||||
|
||||
const reservationOverlaps = (reservation, checkIn, checkOut) => {
|
||||
const existingCheckIn = dateValue(reservation.check_in_at);
|
||||
const existingCheckOut = dateValue(reservation.check_out_at);
|
||||
|
||||
if (!existingCheckIn || !existingCheckOut) return false;
|
||||
|
||||
return existingCheckIn < checkOut && existingCheckOut > checkIn;
|
||||
};
|
||||
|
||||
const allocateRoom = async (reservation, roomType, connection, currentUser, transaction) => {
|
||||
const explicitRoom = await findExplicitRoom(reservation, connection, currentUser, transaction);
|
||||
if (explicitRoom) return explicitRoom;
|
||||
|
||||
const where = scopedWhere(
|
||||
{
|
||||
active: true,
|
||||
status: { [Op.notIn]: ['out_of_service'] },
|
||||
},
|
||||
connection,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
if (roomType) where.room_typeId = roomType.id;
|
||||
|
||||
const candidateRooms = await db.rooms.findAll({ where, transaction, order: [['room_number', 'ASC']] });
|
||||
|
||||
if (!candidateRooms.length) return null;
|
||||
|
||||
const roomIds = candidateRooms.map((room) => room.id);
|
||||
const overlappingReservations = await db.reservations.findAll({
|
||||
where: {
|
||||
roomId: { [Op.in]: roomIds },
|
||||
status: { [Op.notIn]: ['cancelled', 'no_show', 'checked_out'] },
|
||||
check_in_at: { [Op.lt]: reservation.checkOut },
|
||||
check_out_at: { [Op.gt]: reservation.checkIn },
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
const blockedRoomIds = new Set(
|
||||
overlappingReservations
|
||||
.filter((existingReservation) => reservationOverlaps(existingReservation, reservation.checkIn, reservation.checkOut))
|
||||
.map((existingReservation) => existingReservation.roomId),
|
||||
);
|
||||
|
||||
return candidateRooms.find((room) => !blockedRoomIds.has(room.id)) || null;
|
||||
};
|
||||
|
||||
const upsertReservation = async (rawReservation, connection, currentUser, transaction) => {
|
||||
const normalized = normalizeExternalReservation(rawReservation, connection);
|
||||
const providerLabel = normalizeProviderLabel(connection.provider);
|
||||
const importHash = `ota-reservation:${connection.id}:${normalized.externalReservationId}`;
|
||||
const salesChannel = await ensureSalesChannel(connection, currentUser, transaction);
|
||||
const guest = await ensureGuest(normalized, connection, currentUser, transaction);
|
||||
const roomType = await findRoomType(normalized, connection, currentUser, transaction);
|
||||
const room = await allocateRoom(normalized, roomType, connection, currentUser, transaction);
|
||||
const organizationId = currentOrganizationId(currentUser, connection);
|
||||
const existingReservation = await db.reservations.findOne({ where: { importHash }, transaction });
|
||||
const status = normalizeStatus(normalized.status);
|
||||
const isCancelled = status === 'cancelled';
|
||||
const payload = {
|
||||
reservation_number: normalized.reservationNumber,
|
||||
check_in_at: normalized.checkIn,
|
||||
check_out_at: normalized.checkOut,
|
||||
adults: normalized.adults,
|
||||
children: normalized.children,
|
||||
status,
|
||||
total_amount: normalized.totalAmount,
|
||||
tax_amount: normalized.taxAmount,
|
||||
deposit_amount: normalized.depositAmount,
|
||||
special_requests: normalized.specialRequests,
|
||||
internal_notes: `Imported from ${providerLabel}. External reservation: ${normalized.externalReservationId}.`,
|
||||
cancelled_at: isCancelled ? new Date() : null,
|
||||
cancellation_reason: isCancelled ? normalized.cancellationReason || `Cancelled by ${providerLabel}` : null,
|
||||
importHash,
|
||||
hotelId: normalized.hotelId || connection.hotelId || null,
|
||||
guestId: guest?.id || null,
|
||||
roomId: room?.id || null,
|
||||
room_typeId: roomType?.id || room?.room_typeId || null,
|
||||
rate_planId: isUuid(normalized.ratePlanId) ? normalized.ratePlanId : null,
|
||||
sales_channelId: salesChannel?.id || null,
|
||||
organizationsId: organizationId,
|
||||
updatedById: currentUser?.id || null,
|
||||
};
|
||||
|
||||
let reservationRecord;
|
||||
let action = 'created';
|
||||
|
||||
if (existingReservation) {
|
||||
await existingReservation.update(payload, { transaction });
|
||||
reservationRecord = existingReservation;
|
||||
action = 'updated';
|
||||
} else {
|
||||
reservationRecord = await db.reservations.create(
|
||||
{
|
||||
...payload,
|
||||
createdById: currentUser?.id || null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'checked_in' && room) {
|
||||
await room.update({ status: 'occupied', updatedById: currentUser?.id || null }, { transaction });
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
status,
|
||||
id: reservationRecord.id,
|
||||
reservation_number: reservationRecord.reservation_number,
|
||||
room_id: room?.id || null,
|
||||
room_number: room?.room_number || null,
|
||||
guest_id: guest?.id || null,
|
||||
};
|
||||
};
|
||||
|
||||
const importReservations = async (connection, payload, currentUser, transaction) => {
|
||||
const inboundReservations = normalizeInboundReservations(payload);
|
||||
const results = {
|
||||
received: inboundReservations.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
cancelled: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
reservations: [],
|
||||
};
|
||||
|
||||
for (const rawReservation of inboundReservations) {
|
||||
try {
|
||||
const result = await upsertReservation(rawReservation, connection, currentUser, transaction);
|
||||
results.reservations.push(result);
|
||||
|
||||
if (result.action === 'created') results.created += 1;
|
||||
if (result.action === 'updated') results.updated += 1;
|
||||
if (result.status === 'cancelled') results.cancelled += 1;
|
||||
} catch (error) {
|
||||
console.error('OTA reservation import failed', error);
|
||||
results.skipped += 1;
|
||||
results.errors.push(error.message || 'Reservation import failed.');
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const roomStatusExport = (status) => {
|
||||
if (status === 'out_of_service') return 'blocked';
|
||||
if (status === 'occupied') return 'occupied';
|
||||
if (['dirty', 'cleaning'].includes(status)) return 'available_needs_housekeeping';
|
||||
return 'available';
|
||||
};
|
||||
|
||||
const buildExportSnapshot = async (connection, payload, currentUser, transaction) => {
|
||||
const startDate = dateValue(payload?.startDate || payload?.start_date) || new Date();
|
||||
const days = Math.min(Math.max(numberValue(payload?.days, 30), 1), 180);
|
||||
const dateRange = eachDate(startDate, days);
|
||||
const roomWhere = scopedWhere({ active: true }, connection, currentUser);
|
||||
const roomTypeWhere = scopedWhere({ active: true }, connection, currentUser);
|
||||
|
||||
const [rooms, roomTypes, reservations, ratePlans, pricingRules] = await Promise.all([
|
||||
db.rooms.findAll({
|
||||
where: roomWhere,
|
||||
include: [{ model: db.room_types, as: 'room_type' }],
|
||||
transaction,
|
||||
order: [['room_number', 'ASC']],
|
||||
}),
|
||||
db.room_types.findAll({ where: roomTypeWhere, transaction, order: [['name', 'ASC']] }),
|
||||
db.reservations.findAll({
|
||||
where: scopedWhere(
|
||||
{
|
||||
status: { [Op.notIn]: ['cancelled', 'no_show'] },
|
||||
check_in_at: { [Op.lt]: addDays(startDate, days) },
|
||||
check_out_at: { [Op.gt]: startDate },
|
||||
},
|
||||
connection,
|
||||
currentUser,
|
||||
),
|
||||
transaction,
|
||||
}),
|
||||
db.rate_plans.findAll({
|
||||
where: scopedWhere({ active: true }, connection, currentUser),
|
||||
transaction,
|
||||
order: [['name', 'ASC']],
|
||||
}),
|
||||
db.dynamic_pricing_rules.findAll({
|
||||
where: scopedWhere({ active: true }, connection, currentUser),
|
||||
transaction,
|
||||
order: [['name', 'ASC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeRooms = rooms.filter((room) => room.status !== 'out_of_service');
|
||||
const roomsByType = activeRooms.reduce((accumulator, room) => {
|
||||
const roomTypeId = room.room_typeId || 'unassigned';
|
||||
accumulator[roomTypeId] = accumulator[roomTypeId] || [];
|
||||
accumulator[roomTypeId].push(room);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const inventory = [];
|
||||
|
||||
for (const roomType of roomTypes) {
|
||||
const typedRooms = roomsByType[roomType.id] || [];
|
||||
|
||||
for (const date of dateRange) {
|
||||
const currentDate = new Date(`${date}T12:00:00Z`);
|
||||
const bookedRoomIds = new Set(
|
||||
reservations
|
||||
.filter(
|
||||
(reservation) =>
|
||||
reservation.room_typeId === roomType.id &&
|
||||
dateValue(reservation.check_in_at) <= currentDate &&
|
||||
dateValue(reservation.check_out_at) > currentDate,
|
||||
)
|
||||
.map((reservation) => reservation.roomId)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
inventory.push({
|
||||
date,
|
||||
room_type_id: roomType.id,
|
||||
room_type_name: roomType.name,
|
||||
rooms_total: typedRooms.length,
|
||||
rooms_booked: bookedRoomIds.size,
|
||||
rooms_available: Math.max(typedRooms.length - bookedRoomIds.size, 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
provider: connection.provider,
|
||||
account_identifier: connection.account_identifier,
|
||||
inventory_sync_enabled: Boolean(connection.inventory_sync_enabled),
|
||||
rate_sync_enabled: Boolean(connection.rate_sync_enabled),
|
||||
rooms: rooms.map((room) => ({
|
||||
id: room.id,
|
||||
room_number: room.room_number,
|
||||
room_type_id: room.room_typeId,
|
||||
room_type_name: room.room_type?.name || null,
|
||||
status: roomStatusExport(room.status),
|
||||
active: room.active,
|
||||
})),
|
||||
inventory,
|
||||
rates: roomTypes.map((roomType) => ({
|
||||
room_type_id: roomType.id,
|
||||
room_type_name: roomType.name,
|
||||
base_rate: numberValue(roomType.base_rate, 0),
|
||||
currency: 'USD',
|
||||
active_rate_plans: ratePlans.map((ratePlan) => ({
|
||||
id: ratePlan.id,
|
||||
name: ratePlan.name,
|
||||
plan_type: ratePlan.plan_type,
|
||||
refundable: ratePlan.refundable,
|
||||
deposit_percent: numberValue(ratePlan.deposit_percent, 0),
|
||||
})),
|
||||
dynamic_pricing_rules: pricingRules
|
||||
.filter((rule) => !rule.room_typeId || rule.room_typeId === roomType.id)
|
||||
.map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
rule_type: rule.rule_type,
|
||||
adjustment_percent: numberValue(rule.adjustment_percent, 0),
|
||||
adjustment_amount: numberValue(rule.adjustment_amount, 0),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = class OtaIntegrationService {
|
||||
static async syncConnection(connectionId, payload = {}, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const connection = await getConnection(connectionId, transaction);
|
||||
const mode = payload.mode || 'full';
|
||||
const shouldImport = ['full', 'import', 'reservations'].includes(mode);
|
||||
const shouldExport = ['full', 'export', 'inventory', 'rates'].includes(mode);
|
||||
const inbound = shouldImport
|
||||
? await importReservations(connection, payload, currentUser, transaction)
|
||||
: { received: 0, created: 0, updated: 0, cancelled: 0, skipped: 0, errors: [], reservations: [] };
|
||||
const outbound = shouldExport
|
||||
? await buildExportSnapshot(connection, payload, currentUser, transaction)
|
||||
: null;
|
||||
const nextStatus = inbound.errors.length ? 'warning' : 'ok';
|
||||
|
||||
if (!payload.dryRun) {
|
||||
await connection.update(
|
||||
{
|
||||
last_sync_at: new Date(),
|
||||
sync_status: nextStatus,
|
||||
updatedById: currentUser?.id || null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.dryRun) {
|
||||
await transaction.rollback();
|
||||
} else {
|
||||
await transaction.commit();
|
||||
}
|
||||
|
||||
return {
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
account_identifier: connection.account_identifier,
|
||||
sync_status: payload.dryRun ? connection.sync_status : nextStatus,
|
||||
last_sync_at: payload.dryRun ? connection.last_sync_at : new Date().toISOString(),
|
||||
},
|
||||
inbound,
|
||||
outbound,
|
||||
dryRun: Boolean(payload.dryRun),
|
||||
message: inbound.errors.length
|
||||
? 'OTA sync completed with warnings. Review skipped reservations.'
|
||||
: 'OTA sync completed successfully.',
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async syncAll(payload = {}, currentUser) {
|
||||
const connections = await db.ota_connections.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ inventory_sync_enabled: true },
|
||||
{ rate_sync_enabled: true },
|
||||
{ sync_status: { [Op.ne]: 'disabled' } },
|
||||
],
|
||||
},
|
||||
order: [['provider', 'ASC']],
|
||||
});
|
||||
const results = [];
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
results.push(await this.syncConnection(connection.id, payload, currentUser));
|
||||
} catch (error) {
|
||||
console.error(`OTA sync failed for connection ${connection.id}`, error);
|
||||
await connection.update({ sync_status: 'error', updatedById: currentUser?.id || null });
|
||||
results.push({
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
account_identifier: connection.account_identifier,
|
||||
sync_status: 'error',
|
||||
},
|
||||
error: error.message || 'OTA sync failed.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: results.length,
|
||||
ok: results.filter((result) => !result.error && !result.inbound?.errors?.length).length,
|
||||
warnings: results.filter((result) => result.inbound?.errors?.length).length,
|
||||
errors: results.filter((result) => result.error).length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
static async exportConnection(connectionId, payload = {}, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const connection = await getConnection(connectionId, transaction);
|
||||
const snapshot = await buildExportSnapshot(connection, payload, currentUser, transaction);
|
||||
await transaction.commit();
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import AsideMenuList from './AsideMenuList'
|
||||
import { MenuAsideItem } from '../interfaces'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/front-desk',
|
||||
icon: icon.mdiCalendarClock,
|
||||
label: 'Front Desk',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
1618
frontend/src/pages/front-desk.tsx
Normal file
1618
frontend/src/pages/front-desk.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,166 +1,205 @@
|
||||
import { mdiCalendarClock, mdiChartTimelineVariant, mdiHomeCity, mdiLogin, mdiShieldKeyOutline } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import React, { ReactElement } from 'react'
|
||||
import BaseButton from '../components/BaseButton'
|
||||
import CardBox from '../components/CardBox'
|
||||
import { getPageTitle } from '../config'
|
||||
import LayoutGuest from '../layouts/Guest'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Front desk cockpit',
|
||||
description: 'Create walk-ins, see room availability, confirm arrivals, and move departures into housekeeping.',
|
||||
icon: mdiCalendarClock,
|
||||
},
|
||||
{
|
||||
title: 'Multi-hotel ready',
|
||||
description: 'Built around hotel-level operations, staff roles, and isolated workflows for owners and teams.',
|
||||
icon: mdiShieldKeyOutline,
|
||||
},
|
||||
{
|
||||
title: 'Revenue intelligence',
|
||||
description: 'A clean foundation for ADR, RevPAR, channel performance, and AI-assisted daily recommendations.',
|
||||
icon: mdiChartTimelineVariant,
|
||||
},
|
||||
]
|
||||
|
||||
const stats = [
|
||||
['10', 'hotel credentials'],
|
||||
['50', 'demo rooms'],
|
||||
['200+', 'reservation-ready records'],
|
||||
['24/7', 'cloud PMS workflow'],
|
||||
]
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('background');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'App Preview'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
function Starter() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('HotelPilot PMS')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="HotelPilot PMS is a modern multi-tenant property management system for reservations, front desk, housekeeping, billing, and reporting."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className="min-h-screen bg-slate-50 text-slate-950">
|
||||
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/85 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-700 text-white shadow-lg shadow-blue-700/25">
|
||||
<span className="text-xl font-black">H</span>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-lg font-black tracking-tight">HotelPilot PMS</p>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.24em] text-blue-600">Pilot operations</p>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-8 text-sm font-semibold text-slate-600 md:flex">
|
||||
<a href="#workflow" className="hover:text-blue-700">Workflow</a>
|
||||
<a href="#modules" className="hover:text-blue-700">Modules</a>
|
||||
<a href="#mvp" className="hover:text-blue-700">First slice</a>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" icon={mdiLogin} label="Login" color="whiteDark" />
|
||||
<BaseButton href="/front-desk" label="Open PMS" color="info" className="hidden border-blue-700 bg-blue-700 text-white hover:bg-blue-800 sm:inline-flex" />
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<section className="relative overflow-hidden bg-[radial-gradient(circle_at_top_left,_#dbeafe,_transparent_36%),linear-gradient(135deg,#ffffff_0%,#eff6ff_50%,#e0f2fe_100%)]">
|
||||
<div className="absolute right-0 top-20 h-72 w-72 rounded-full bg-cyan-300/30 blur-3xl" />
|
||||
<div className="absolute bottom-0 left-10 h-80 w-80 rounded-full bg-blue-600/10 blur-3xl" />
|
||||
<div className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center lg:py-28">
|
||||
<div className="relative z-10">
|
||||
<p className="mb-5 inline-flex items-center gap-2 rounded-full border border-blue-200 bg-white/80 px-4 py-2 text-sm font-bold text-blue-800 shadow-sm">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" /> New MVP slice live: front desk operations
|
||||
</p>
|
||||
<h1 className="max-w-4xl text-5xl font-black tracking-[-0.05em] text-slate-950 md:text-7xl">
|
||||
A modern PMS command center for every hotel team.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
HotelPilot PMS brings reservations, room status, check-ins, check-outs, housekeeping, billing, and reporting into a fast blue-white SaaS workspace designed for multi-property operators.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<BaseButton href="/front-desk" label="Try front desk workflow" color="info" className="border-blue-700 bg-blue-700 px-6 py-3 text-white hover:bg-blue-800" />
|
||||
<BaseButton href="/login" label="Admin login" color="whiteDark" className="px-6 py-3" />
|
||||
</div>
|
||||
<div className="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats.map(([value, label]) => (
|
||||
<div key={label} className="rounded-3xl border border-white bg-white/70 p-4 shadow-sm backdrop-blur">
|
||||
<p className="text-3xl font-black text-blue-700">{value}</p>
|
||||
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
<div className="relative z-10">
|
||||
<div className="rounded-[2rem] border border-white bg-white/80 p-4 shadow-2xl shadow-blue-900/10 backdrop-blur-xl">
|
||||
<div className="rounded-[1.5rem] bg-slate-950 p-4 text-white">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-2xl bg-blue-500 p-3"><span className="text-lg">☑</span></span>
|
||||
<div>
|
||||
<p className="font-bold">Sunset Resort</p>
|
||||
<p className="text-xs text-slate-400">Today's command center</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs font-bold text-emerald-300">Live</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
['78%', 'Occupancy'],
|
||||
['$8.4k', 'Revenue today'],
|
||||
['14', 'Arrivals'],
|
||||
['9', 'Dirty rooms'],
|
||||
].map(([value, label]) => (
|
||||
<div key={label} className="rounded-2xl bg-white/10 p-4">
|
||||
<p className="text-2xl font-black">{value}</p>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl bg-white p-4 text-slate-900">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="font-black">Availability pulse</p>
|
||||
<span className="text-xs font-bold text-blue-700">7-day view</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{['101 Standard', '201 Deluxe Ocean', '301 Family Suite', '401 Executive'].map((room, index) => (
|
||||
<div key={room} className="grid grid-cols-[7rem_1fr] items-center gap-3 text-xs">
|
||||
<span className="font-semibold text-slate-500">{room}</span>
|
||||
<span className={`h-8 rounded-full ${index === 1 ? 'bg-blue-600' : index === 2 ? 'bg-amber-300' : 'bg-emerald-300'}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="workflow" className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<p className="mb-3 inline-flex rounded-full bg-blue-100 px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-blue-700">Workflow-first MVP</p>
|
||||
<h2 className="text-4xl font-black tracking-tight">Not just tables — an actual daily hotel flow.</h2>
|
||||
<p className="mt-3 text-slate-600">The first delivery stitches together the operational path a front desk agent needs most often.</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-4">
|
||||
{['Search availability', 'Register guest', 'Confirm reservation', 'Check in / housekeeping'].map((step, index) => (
|
||||
<div key={step} className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<span className="mb-5 inline-grid h-10 w-10 place-items-center rounded-2xl bg-blue-700 text-sm font-black text-white">{index + 1}</span>
|
||||
<p className="text-lg font-black">{step}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">Built into the new authenticated front desk cockpit for staff.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="modules" className="bg-white py-16">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
{featureCards.map((feature) => (
|
||||
<CardBox key={feature.title} className="border-0 bg-slate-50 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="mb-5 inline-grid h-14 w-14 place-items-center rounded-2xl bg-blue-700 text-white">
|
||||
<span className="text-xl">✦</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black tracking-tight">{feature.title}</h3>
|
||||
<p className="mt-3 leading-7 text-slate-600">{feature.description}</p>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mvp" className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="overflow-hidden rounded-[2rem] bg-blue-700 p-8 text-white shadow-xl shadow-blue-700/20 md:p-12">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_0.7fr] lg:items-center">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-black uppercase tracking-[0.2em] text-blue-100">
|
||||
<span>✦</span> First iteration
|
||||
</p>
|
||||
<h2 className="text-4xl font-black tracking-tight">Open the PMS and run a booking from input to operational status.</h2>
|
||||
<p className="mt-4 max-w-2xl leading-7 text-blue-50">
|
||||
This public page keeps the login path visible and sends authenticated users into the new Front Desk page. Existing admin CRUD remains available for deeper data management.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
|
||||
<BaseButton href="/front-desk" icon={mdiHomeCity} label="Launch Front Desk" color="white" className="py-3" />
|
||||
<BaseButton href="/login" icon={mdiLogin} label="Go to admin login" color="white" outline className="border-white py-3 text-white hover:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
return <LayoutGuest>{page}</LayoutGuest>
|
||||
}
|
||||
|
||||
export default Starter
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
64
frontend/src/pages/stripe/cancel.tsx
Normal file
64
frontend/src/pages/stripe/cancel.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiArrowLeft,
|
||||
} from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { ReactElement, useMemo } from 'react'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
|
||||
const StripeCancelPage = () => {
|
||||
const router = useRouter()
|
||||
const reservationId = useMemo(() => {
|
||||
const value = router.query.reservationId
|
||||
return typeof value === 'string' ? value : ''
|
||||
}, [router.query.reservationId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Stripe Payment Cancelled')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiAlertCircleOutline} title="Stripe payment cancelled" main>
|
||||
<BaseButton href="/front-desk" icon={mdiArrowLeft} label="Back to Front Desk" color="info" />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="border-0 shadow-sm ring-1 ring-amber-100">
|
||||
<div className="rounded-3xl bg-amber-50 p-6 text-amber-900">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-amber-600">HotelPilot PMS</p>
|
||||
<h2 className="mt-2 text-3xl font-black">Checkout was cancelled</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-amber-700">
|
||||
No Stripe payment was captured. The reservation remains open, so front desk staff can retry collection,
|
||||
accept another method, or continue the check-in workflow based on hotel policy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{reservationId && (
|
||||
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">
|
||||
Reservation reference: <span className="font-bold">{reservationId}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/front-desk" color="info" label="Retry from Front Desk" />
|
||||
<BaseButton href="/reservations/reservations-list" color="whiteDark" label="Open Reservations" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
StripeCancelPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default StripeCancelPage
|
||||
289
frontend/src/pages/stripe/success.tsx
Normal file
289
frontend/src/pages/stripe/success.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCreditCardCheckOutline,
|
||||
} from '@mdi/js'
|
||||
import axios from 'axios'
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
|
||||
type StripeSession = {
|
||||
id: string
|
||||
status?: string
|
||||
payment_status?: string
|
||||
amount_total?: number
|
||||
currency?: string
|
||||
customer_email?: string
|
||||
metadata?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
type PaymentRecord = {
|
||||
id: string
|
||||
stripe_charge_reference?: string
|
||||
}
|
||||
|
||||
type PaymentsListResponse = {
|
||||
rows?: PaymentRecord[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
type DemoReservation = {
|
||||
id: string
|
||||
totalAmount: number
|
||||
paidAmount: number
|
||||
}
|
||||
|
||||
type FrontDeskDemoState = {
|
||||
rooms?: unknown[]
|
||||
reservations?: DemoReservation[]
|
||||
}
|
||||
|
||||
const storageKey = 'hotelpilot-front-desk-demo'
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const moneyFromCents = (amountInCents?: number, currency = 'usd') =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
maximumFractionDigits: 0,
|
||||
}).format((amountInCents || 0) / 100)
|
||||
|
||||
const apiErrorMessage = (error: unknown) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data
|
||||
|
||||
if (typeof data === 'string') return data
|
||||
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
const message = (data as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error instanceof Error) return error.message
|
||||
|
||||
return 'Unable to verify the Stripe payment.'
|
||||
}
|
||||
|
||||
const isUuid = (value: string) => uuidPattern.test(value)
|
||||
|
||||
const markDemoReservationPaid = (reservationId: string, amountPaid: number) => {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
const saved = window.localStorage.getItem(storageKey)
|
||||
if (!saved) return false
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as FrontDeskDemoState
|
||||
let updated = false
|
||||
|
||||
if (!Array.isArray(parsed.reservations)) return false
|
||||
|
||||
const reservations = parsed.reservations.map((reservation) => {
|
||||
if (reservation.id !== reservationId) return reservation
|
||||
|
||||
updated = true
|
||||
return {
|
||||
...reservation,
|
||||
paidAmount: Math.min(reservation.totalAmount, reservation.paidAmount + amountPaid),
|
||||
}
|
||||
})
|
||||
|
||||
if (!updated) return false
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify({ ...parsed, reservations }))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update HotelPilot demo reservation after Stripe success', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const syncPmsPaymentRecord = async (session: StripeSession, reservationId: string, amountPaid: number) => {
|
||||
const existingPayment = await axios.get<PaymentsListResponse>('payments', {
|
||||
params: {
|
||||
limit: 1,
|
||||
page: 0,
|
||||
stripe_charge_reference: session.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingPayment.data.rows?.length) {
|
||||
return 'Payment verified. A PMS payment record already exists for this Stripe session.'
|
||||
}
|
||||
|
||||
await axios.post('payments', {
|
||||
data: {
|
||||
method: 'stripe',
|
||||
amount: amountPaid,
|
||||
status: 'captured',
|
||||
paid_at: new Date().toISOString(),
|
||||
stripe_payment_intent_reference: session.id,
|
||||
stripe_charge_reference: session.id,
|
||||
receipt_number: session.metadata?.reservationNumber || session.id,
|
||||
notes: `Stripe Checkout verified for reservation ${session.metadata?.reservationNumber || reservationId}.`,
|
||||
reservation: reservationId,
|
||||
},
|
||||
})
|
||||
|
||||
return 'Payment verified and saved as a captured PMS payment record.'
|
||||
}
|
||||
|
||||
const StripeSuccessPage = () => {
|
||||
const router = useRouter()
|
||||
const [session, setSession] = useState<StripeSession | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [syncMessage, setSyncMessage] = useState('')
|
||||
const [syncErrorMessage, setSyncErrorMessage] = useState('')
|
||||
const syncedSessionRef = useRef('')
|
||||
|
||||
const sessionId = useMemo(() => {
|
||||
const value = router.query.session_id
|
||||
return typeof value === 'string' ? value : ''
|
||||
}, [router.query.session_id])
|
||||
|
||||
const reservationIdFromQuery = useMemo(() => {
|
||||
const value = router.query.reservationId
|
||||
return typeof value === 'string' ? value : ''
|
||||
}, [router.query.reservationId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
|
||||
if (!sessionId) {
|
||||
setErrorMessage('Stripe did not provide a Checkout session id.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const verifySession = async () => {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setSyncErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await axios.get<StripeSession>(`/stripe/checkout-session/${sessionId}`)
|
||||
setSession(response.data)
|
||||
|
||||
const reservationId = response.data.metadata?.reservationId || reservationIdFromQuery
|
||||
const amountPaid = (response.data.amount_total || 0) / 100
|
||||
|
||||
if (response.data.payment_status === 'paid' && reservationId && amountPaid > 0) {
|
||||
if (syncedSessionRef.current === response.data.id) return
|
||||
|
||||
syncedSessionRef.current = response.data.id
|
||||
|
||||
if (isUuid(reservationId)) {
|
||||
try {
|
||||
setSyncMessage(await syncPmsPaymentRecord(response.data, reservationId, amountPaid))
|
||||
} catch (syncError) {
|
||||
console.error('Stripe payment verified, but PMS payment sync failed', syncError)
|
||||
setSyncErrorMessage(`Payment verified by Stripe, but PMS payment sync failed: ${apiErrorMessage(syncError)}`)
|
||||
}
|
||||
} else {
|
||||
const updated = markDemoReservationPaid(reservationId, amountPaid)
|
||||
setSyncMessage(
|
||||
updated
|
||||
? 'Payment verified. The Front Desk demo reservation was marked as paid.'
|
||||
: 'Payment verified. Return to Front Desk to continue the stay workflow.',
|
||||
)
|
||||
}
|
||||
} else if (response.data.payment_status === 'paid') {
|
||||
setSyncMessage('Payment verified by Stripe.')
|
||||
} else {
|
||||
setSyncMessage('Stripe returned the session, but the payment is not marked paid yet.')
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.warn('Stripe payment verification failed', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
})
|
||||
} else {
|
||||
console.error('Stripe payment verification failed', error)
|
||||
}
|
||||
|
||||
setErrorMessage(apiErrorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
verifySession()
|
||||
}, [reservationIdFromQuery, router.isReady, sessionId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Stripe Payment Success')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiCreditCardCheckOutline} title="Stripe payment success" main>
|
||||
<BaseButton href="/front-desk" icon={mdiArrowLeft} label="Back to Front Desk" color="info" />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="border-0 shadow-sm ring-1 ring-emerald-100">
|
||||
<div className="rounded-3xl bg-emerald-50 p-6 text-emerald-900">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-600">HotelPilot PMS</p>
|
||||
<h2 className="mt-2 text-3xl font-black">Payment return received</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-emerald-700">
|
||||
HotelPilot is verifying the Stripe Checkout session before updating the front desk payment state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{loading && <p className="rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">Verifying Stripe payment...</p>}
|
||||
|
||||
{!loading && errorMessage && (
|
||||
<p className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-800">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && session && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-100 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Session</p>
|
||||
<p className="mt-1 break-all font-bold text-slate-900">{session.id}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Payment status</p>
|
||||
<p className="mt-1 font-bold text-slate-900">{session.payment_status || 'Unknown'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Amount</p>
|
||||
<p className="mt-1 font-bold text-slate-900">{moneyFromCents(session.amount_total, session.currency)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Guest email</p>
|
||||
<p className="mt-1 font-bold text-slate-900">{session.customer_email || 'Not provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncMessage && <p className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm font-medium text-emerald-800">{syncMessage}</p>}
|
||||
{syncErrorMessage && <p className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-800">{syncErrorMessage}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/front-desk" color="info" label="Return to Front Desk" />
|
||||
<BaseButton href="/payments/payments-list" color="whiteDark" label="Open Payments CRUD" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
StripeSuccessPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default StripeSuccessPage
|
||||
Loading…
x
Reference in New Issue
Block a user