const API_BASE = 'https://api.newbook.cloud/rest/' async function getCredentials() { const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook` const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, signal: AbortSignal.timeout(5000), }) if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching Newbook credentials`) const s = await res.json() return { username: s.username || '', password: s.password || '', apiKey: s.api_key || '', region: s.region || 'eu', } } async function callApi(endpoint, data = {}) { const creds = await getCredentials() if (!creds.username || !creds.password || !creds.apiKey) { throw new Error('Newbook API credentials not configured') } const body = { ...data, region: creds.region, api_key: creds.apiKey } const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), 30000) try { const res = await fetch(API_BASE + endpoint, { method: 'POST', signal: ctrl.signal, headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify(body), }) clearTimeout(timer) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Newbook API ${res.status}: ${text.slice(0, 200)}`) } return await res.json() } catch (err) { clearTimeout(timer) throw err } } function identifyCardType(transaction) { const type = (transaction.payment_type || transaction.type || '').toLowerCase() const method = (transaction.method || '').toLowerCase() const txMethod = (transaction.payment_transaction_method || '').toLowerCase() const combined = type + ' ' + method if (combined.includes('cash')) return 'cash' if (combined.includes('eft') || combined.includes('bacs') || combined.includes('bank transfer') || combined.includes('direct debit')) return 'bacs' if (combined.includes('amex') || combined.includes('american express')) return 'amex' if (combined.includes('visa') || combined.includes('mastercard') || combined.includes('master card') || combined.includes(' mc ')) return 'visa_mc' // Gateway/automated transactions default to visa_mc if (txMethod === 'automated' || txMethod === 'gateway' || txMethod === 'cc_gateway') { if (combined.includes('card') || combined.includes('credit') || combined.includes('debit')) return 'visa_mc' return 'visa_mc' } return 'other' } function normaliseTransaction(transaction) { // Newbook: payments negative, refunds positive (accounting view) // We negate to get revenue view: payments positive, refunds negative const amount = -parseFloat(transaction.item_amount || 0) return { payment_id: transaction.item_id || '', booking_id: transaction.booking_id || '', guest_name: transaction.account_for_name || '', payment_date: transaction.item_date || '', payment_type: transaction.payment_type || '', transaction_method: transaction.payment_transaction_method || 'manual', card_type: identifyCardType(transaction), amount, item_type: transaction.item_type || '', } } const PAYMENT_TYPES = new Set(['payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided']) export async function fetchPaymentsByDate(date, returnRaw = false) { const response = await callApi('reports_transaction_flow', { period_from: `${date} 00:00:00`, period_to: `${date} 23:59:59`, data_offset: 0, data_limit: 1000, }) if (!response?.data) throw new Error('No payment data returned') const payments = response.data .filter(t => PAYMENT_TYPES.has(t.item_type) && t.payment_type !== 'balance_transfer') .map(normaliseTransaction) return returnRaw ? { payments, raw_data: response } : payments } export async function fetchPaymentsByDateRange(startDate, endDate) { const response = await callApi('reports_transaction_flow', { period_from: `${startDate} 00:00:00`, period_to: `${endDate} 23:59:59`, data_offset: 0, data_limit: 5000, }) if (!response?.data) throw new Error('No payment data returned') const byDate = {} for (const t of response.data) { if (!PAYMENT_TYPES.has(t.item_type) || t.payment_type === 'balance_transfer') continue const date = (t.item_date || '').slice(0, 10) if (!date) continue if (!byDate[date]) byDate[date] = [] byDate[date].push(normaliseTransaction(t)) } return byDate } export async function fetchDailyAuditSummary(date) { const response = await callApi('reports_daily_audit_summary', { period_from: `${date} 00:00:00`, period_to: `${date} 23:59:59`, }) return response?.data ?? [] } export async function fetchEarnedRevenue(startDate, endDate, periodIncrement = 'day') { const response = await callApi('reports_earned_revenue', { period_from: `${startDate} 00:00:00`, period_to: `${endDate} 23:59:59`, period_increment: periodIncrement, }) return response?.data ?? [] } export async function fetchOccupancy(startDate, endDate) { const response = await callApi('reports_occupancy', { period_from: `${startDate} 00:00:00`, period_to: `${endDate} 23:59:59`, }) return response?.data ?? [] } export async function fetchBookingsList(startDate, endDate) { const response = await callApi('bookings_list', { period_from: `${startDate} 00:00:00`, period_to: `${endDate} 23:59:59`, list_type: 'staying', }) return response?.data ?? [] } export async function fetchSitesList() { const response = await callApi('sites_list', {}) return response?.data ?? [] } export async function fetchGlAccountList() { const response = await callApi('gl_account_list', {}) return response?.data ?? [] } export async function fetchGlAccountGroupsList() { const response = await callApi('gl_account_groups_list', {}) return response?.data ?? [] } export async function fetchGlAccountsGrouped() { const data = await fetchGlAccountGroupsList() const groups = {} for (const item of data) { if (item.gl_group_id && item.gl_group_name && !groups[item.gl_group_id]) { let displayName = item.gl_group_name if (displayName.includes(' - ')) displayName = displayName.split(' - ').slice(1).join(' - ').trim() groups[String(item.gl_group_id)] = displayName } } return groups } export async function fetchDebtorsCreditors(date) { const response = await callApi('reports_balances_dated', { period_from: date }) if (!response?.data) return { creditors: 0, debtors: 0, overall: 0, accounts: [] } let creditors = 0, debtors = 0 const accounts = [] for (const account of response.data) { const balance = parseFloat(account.account_balance || 0) if (balance < 0) creditors += Math.abs(balance) else if (balance > 0) debtors += balance accounts.push({ name: account.account_for_name || 'Unknown', balance }) } return { creditors, debtors, overall: debtors - creditors, accounts } } export async function testConnection() { try { const data = await fetchSitesList() return { success: true, message: `Connected. Found ${data.length} site(s).` } } catch (err) { return { success: false, message: err.message } } } export function calculatePaymentTotals(payments) { const totals = { cash: 0, manual_visa_mc: 0, manual_amex: 0, gateway_visa_mc: 0, gateway_amex: 0, bacs: 0 } for (const p of payments) { const amt = parseFloat(p.amount) if (p.card_type === 'cash') { totals.cash += amt } else if (p.card_type === 'bacs') { totals.bacs += amt } else if (p.transaction_method === 'manual') { if (p.card_type === 'amex') totals.manual_amex += amt else if (p.card_type === 'visa_mc') totals.manual_visa_mc += amt } else { if (p.card_type === 'amex') totals.gateway_amex += amt else if (p.card_type === 'visa_mc') totals.gateway_visa_mc += amt } } return totals } export function parseTillTransactions(rawData) { if (!rawData?.data) return [] const grouped = {} for (const t of rawData.data) { if (!PAYMENT_TYPES.has(t.item_type)) continue if (t.payment_transaction_method !== 'manual') continue if (t.payment_type === 'balance_transfer') continue const desc = t.item_description || '' const match = desc.match(/^Ticket:\s*(\d+)\s*-\s*(.+)$/i) if (!match) continue const paymentType = match[2].trim() const amount = -parseFloat(t.item_amount || 0) if (amount === 0) continue if (!grouped[paymentType]) grouped[paymentType] = { payment_type: paymentType, quantity: 0, total_value: 0 } grouped[paymentType].quantity += amount > 0 ? 1 : -1 grouped[paymentType].total_value += amount } return Object.values(grouped) } export function parseTransactionBreakdown(rawData) { const reception_manual = {}, reception_gateway = {}, restaurant_bar = {} if (!rawData?.data) return { reception_manual, reception_gateway, restaurant_bar } function categorise(type) { const t = type.toLowerCase() if (t.includes('cash')) return 'Cash' if (t.includes('bacs') || t.includes('eft') || t.includes('bank transfer') || t.includes('direct debit')) return 'BACS' return 'Card' } for (const t of rawData.data) { if (!PAYMENT_TYPES.has(t.item_type)) continue if (t.payment_type === 'balance_transfer') continue const method = t.payment_transaction_method || '' const desc = t.item_description || '' const payType = t.payment_type || '' const isVoided = t.item_type === 'payments_voided' || t.item_type === 'refunds_voided' const displayType = isVoided ? payType + ' -void' : payType const category = categorise(payType) const item = { time: t.item_date || '', payment_type: displayType, details: '', amount: parseFloat(t.item_amount || 0), is_voided: isVoided, } const ticketMatch = desc.match(/^Ticket:\s*(\d+)\s*-\s*(.+)$/i) if (ticketMatch) { item.details = `Ticket: ${ticketMatch[1]}` if (!restaurant_bar[category]) restaurant_bar[category] = [] restaurant_bar[category].push(item) } else { const bookingId = t.booking_id || '' const name = t.account_for_name || '' item.details = bookingId && name ? `#${bookingId} - ${name}` : bookingId ? `#${bookingId}` : name || desc if (method === 'manual') { if (!reception_manual[category]) reception_manual[category] = [] reception_manual[category].push(item) } else if (['cc_gateway', 'gateway', 'automated'].includes(method)) { if (!reception_gateway[category]) reception_gateway[category] = [] reception_gateway[category].push(item) } } } return { reception_manual, reception_gateway, restaurant_bar } }