Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
309
backend/src/lib/newbook.js
Normal file
309
backend/src/lib/newbook.js
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue