Wire Newbook credentials to settings service

This commit is contained in:
jtricerolph 2026-07-01 19:33:16 +00:00
commit 63a5a72fa3
32 changed files with 3386 additions and 0 deletions

309
backend/src/lib/newbook.js Normal file
View file

@ -0,0 +1,309 @@
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 fetchGlAccountsGrouped() {
const data = await fetchGlAccountList()
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[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 }
}