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

35
backend/src/auth.js Normal file
View file

@ -0,0 +1,35 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'cashup'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
}
}

141
backend/src/db.js Normal file
View file

@ -0,0 +1,141 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS cash_ups (
id SERIAL PRIMARY KEY,
session_date DATE NOT NULL UNIQUE,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'draft',
total_float_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
total_cash_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
notes TEXT,
submitted_at TIMESTAMPTZ,
submitted_by TEXT
);
CREATE TABLE IF NOT EXISTS denominations (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
count_type TEXT NOT NULL DEFAULT 'takings',
denomination_type TEXT NOT NULL,
denomination_value NUMERIC(10,2) NOT NULL,
quantity INTEGER,
value_entered NUMERIC(10,2),
total_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS card_machines (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
machine_name TEXT NOT NULL,
total_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
amex_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
visa_mc_amount NUMERIC(10,2) NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payment_records (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER REFERENCES cash_ups(id) ON DELETE SET NULL,
newbook_payment_id TEXT,
booking_id TEXT,
guest_name TEXT,
payment_date TIMESTAMPTZ NOT NULL,
payment_type TEXT,
payment_method TEXT,
transaction_method TEXT,
card_type TEXT,
amount NUMERIC(10,2) NOT NULL,
tendered NUMERIC(10,2),
processed_by TEXT,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reconciliation (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
category TEXT NOT NULL,
banked_amount NUMERIC(10,2) NOT NULL,
reported_amount NUMERIC(10,2) NOT NULL,
variance NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_stats (
id SERIAL PRIMARY KEY,
business_date DATE NOT NULL UNIQUE,
gross_sales NUMERIC(10,2) NOT NULL DEFAULT 0,
debtors_creditors_balance NUMERIC(10,2) NOT NULL DEFAULT 0,
rooms_sold INTEGER NOT NULL DEFAULT 0,
total_people INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL DEFAULT 'manual'
);
CREATE TABLE IF NOT EXISTS sales_breakdown (
id SERIAL PRIMARY KEY,
business_date DATE NOT NULL,
category TEXT NOT NULL,
net_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS float_counts (
id SERIAL PRIMARY KEY,
count_type TEXT NOT NULL,
count_date TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_counted NUMERIC(10,2) NOT NULL DEFAULT 0,
total_receipts NUMERIC(10,2) NOT NULL DEFAULT 0,
target_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
variance NUMERIC(10,2) NOT NULL DEFAULT 0,
notes TEXT
);
CREATE TABLE IF NOT EXISTS float_denominations (
id SERIAL PRIMARY KEY,
float_count_id INTEGER NOT NULL REFERENCES float_counts(id) ON DELETE CASCADE,
denomination_value NUMERIC(10,2) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
total_amount NUMERIC(10,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS float_receipts (
id SERIAL PRIMARY KEY,
float_count_id INTEGER NOT NULL REFERENCES float_counts(id) ON DELETE CASCADE,
receipt_value NUMERIC(10,2) NOT NULL,
receipt_description TEXT
);
CREATE TABLE IF NOT EXISTS cash_count_attachments (
id SERIAL PRIMARY KEY,
cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size BIGINT NOT NULL,
mime_type TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`)
// Seed default settings if not present (Newbook credentials live in the settings service)
await pool.query(`
INSERT INTO settings (key, value) VALUES
('default_report_days', '7'),
('petty_cash_float', '200.00'),
('sales_breakdown_columns', '[]'),
('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}')
ON CONFLICT (key) DO NOTHING
`)
}

99
backend/src/index.js Normal file
View file

@ -0,0 +1,99 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { cashupRoutes } from './routes/cashup.js'
import { newbookRoutes } from './routes/newbook.js'
import { reportRoutes } from './routes/reports.js'
import { floatRoutes } from './routes/floats.js'
import { settingsRoutes } from './routes/settings.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads')
const app = Fastify({ logger: true, trustProxy: true })
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy' }))
await app.register(cashupRoutes)
await app.register(newbookRoutes)
await app.register(reportRoutes)
await app.register(floatRoutes)
await app.register(settingsRoutes)
// File upload for cash up receipt attachments
import { requireAuth } from './auth.js'
import { pool } from './db.js'
import { createWriteStream } from 'fs'
import { mkdir } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname } from 'path'
app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, async (req, reply) => {
const cashUpId = parseInt(req.params.cash_up_id)
const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId])
if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' })
const data = await req.file()
if (!data) return reply.status(400).send({ error: 'No file uploaded' })
const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf']
if (!allowed.includes(data.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and PDF files are allowed' })
}
const ext = extname(data.filename) || '.bin'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'cashup', String(cashUpId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of data.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
const filePath = `/cashup/${cashUpId}/${filename}`
const { rows: ins } = await pool.query(
`INSERT INTO cash_count_attachments (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[cashUpId, data.filename, filePath, size, data.mimetype, req.user.email]
)
return ins.rows[0]
})
app.delete('/api/attachments/:id', { preHandler: requireAuth }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM cash_count_attachments WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
// Delete file from disk (best effort)
const { unlink } = await import('fs/promises')
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
await pool.query('DELETE FROM cash_count_attachments WHERE id = $1', [req.params.id])
return { message: 'Deleted' }
})
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

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 }
}

View file

@ -0,0 +1,154 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
export async function cashupRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/cashup?date=YYYY-MM-DD
app.get('/api/cashup', async (req, reply) => {
const { date } = req.query
if (!date) return reply.status(400).send({ error: 'date required' })
const { rows } = await pool.query(
'SELECT * FROM cash_ups WHERE session_date = $1', [date]
)
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const cashUp = rows[0]
const [denoms, machines, recon, attachments] = await Promise.all([
pool.query('SELECT * FROM denominations WHERE cash_up_id = $1 ORDER BY denomination_value DESC', [cashUp.id]),
pool.query('SELECT * FROM card_machines WHERE cash_up_id = $1', [cashUp.id]),
pool.query('SELECT * FROM reconciliation WHERE cash_up_id = $1', [cashUp.id]),
pool.query('SELECT * FROM cash_count_attachments WHERE cash_up_id = $1 ORDER BY uploaded_at DESC', [cashUp.id]),
])
return {
cash_up: cashUp,
denominations: denoms.rows,
card_machines: machines.rows,
reconciliation: recon.rows,
attachments: attachments.rows,
}
})
// POST /api/cashup/save
app.post('/api/cashup/save', async (req, reply) => {
const { session_date, status, notes, denominations = [], card_machines = [] } = req.body
if (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
let totalFloat = 0, totalCash = 0
for (const d of denominations) {
if (d.count_type === 'float') totalFloat += parseFloat(d.total_amount || 0)
else totalCash += parseFloat(d.total_amount || 0)
}
const existing = await pool.query('SELECT id, status FROM cash_ups WHERE session_date = $1', [session_date])
if (existing.rows.length && existing.rows[0].status === 'final') {
return reply.status(409).send({ error: 'Cannot edit a finalised cash up' })
}
let cashUpId
if (existing.rows.length) {
cashUpId = existing.rows[0].id
const updateData = [
status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
new Date(), cashUpId,
]
await pool.query(
`UPDATE cash_ups SET status=$1, total_float_counted=$2, total_cash_counted=$3, notes=$4,
submitted_at=$5, submitted_by=$6, updated_at=$7 WHERE id=$8`,
updateData
)
} else {
const ins = await pool.query(
`INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[
session_date, req.user.email, status, totalFloat, totalCash, notes || null,
status === 'final' ? new Date() : null,
status === 'final' ? req.user.email : null,
]
)
cashUpId = ins.rows[0].id
}
// Replace denominations
await pool.query('DELETE FROM denominations WHERE cash_up_id = $1', [cashUpId])
for (const d of denominations) {
await pool.query(
`INSERT INTO denominations (cash_up_id, count_type, denomination_type, denomination_value, quantity, value_entered, total_amount)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[cashUpId, d.count_type || 'takings', d.type, parseFloat(d.value), d.quantity ?? null, d.value_entered ?? null, parseFloat(d.total_amount)]
)
}
// Replace card machines
await pool.query('DELETE FROM card_machines WHERE cash_up_id = $1', [cashUpId])
for (const m of card_machines) {
await pool.query(
`INSERT INTO card_machines (cash_up_id, machine_name, total_amount, amex_amount, visa_mc_amount)
VALUES ($1,$2,$3,$4,$5)`,
[cashUpId, m.name, parseFloat(m.total || 0), parseFloat(m.amex || 0), parseFloat(m.visa_mc || 0)]
)
}
return { message: status === 'final' ? 'Cash up submitted.' : 'Saved as draft.', cash_up_id: cashUpId }
})
// DELETE /api/cashup/:id
app.delete('/api/cashup/:id', async (req, reply) => {
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
if (rows[0].status === 'final') return reply.status(409).send({ error: 'Cannot delete a finalised cash up' })
await pool.query('DELETE FROM cash_ups WHERE id = $1', [req.params.id])
return { message: 'Deleted' }
})
// POST /api/cashup/bulk-finalize
app.post('/api/cashup/bulk-finalize', async (req, reply) => {
const { ids } = req.body
if (!Array.isArray(ids) || !ids.length) return reply.status(400).send({ error: 'ids required' })
let success = 0, failed = []
for (const id of ids) {
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [id])
if (!rows.length || rows[0].status !== 'draft') { failed.push(id); continue }
await pool.query(
'UPDATE cash_ups SET status=$1, submitted_at=$2 WHERE id=$3',
['final', new Date(), id]
)
success++
}
return { success, failed_count: failed.length }
})
// GET /api/cashup/history
app.get('/api/cashup/history', async (req, reply) => {
const { status, from, to, offset = 0, limit = 20 } = req.query
const conditions = []
const params = []
if (status && status !== 'all') { params.push(status); conditions.push(`status = $${params.length}`) }
if (from) { params.push(from); conditions.push(`session_date >= $${params.length}`) }
if (to) { params.push(to); conditions.push(`session_date <= $${params.length}`) }
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
params.push(parseInt(limit), parseInt(offset))
const { rows } = await pool.query(
`SELECT * FROM cash_ups ${where} ORDER BY session_date DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
)
const countParams = params.slice(0, params.length - 2)
const { rows: countRows } = await pool.query(`SELECT COUNT(*) FROM cash_ups ${where}`, countParams)
return { rows, total: parseInt(countRows[0].count) }
})
}

View file

@ -0,0 +1,95 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
export async function floatRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/floats/save
// body: { count_type, count_date, denominations, receipts?, total_counted, total_receipts?, target_amount?, variance?, notes }
app.post('/api/floats/save', async (req, reply) => {
const {
count_type, count_date, denominations = [], receipts = [],
total_counted, total_receipts = 0, target_amount = 0, variance = 0, notes = '',
} = req.body
const validTypes = ['petty_cash', 'change_tin', 'safe_cash']
if (!validTypes.includes(count_type)) return reply.status(400).send({ error: 'invalid count_type' })
if (!count_date) return reply.status(400).send({ error: 'count_date required' })
const ins = await pool.query(
`INSERT INTO float_counts (count_type, count_date, created_by, total_counted, total_receipts, target_amount, variance, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
[count_type, count_date, req.user.email, parseFloat(total_counted), parseFloat(total_receipts),
parseFloat(target_amount), parseFloat(variance), notes]
)
const countId = ins.rows[0].id
for (const d of denominations) {
await pool.query(
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, total_amount)
VALUES ($1,$2,$3,$4)`,
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseFloat(d.total)]
)
}
if (count_type === 'petty_cash') {
for (const r of receipts) {
await pool.query(
`INSERT INTO float_receipts (float_count_id, receipt_value, receipt_description)
VALUES ($1,$2,$3)`,
[countId, parseFloat(r.amount), r.description || '']
)
}
}
return { message: 'Count saved.', count_id: countId }
})
// GET /api/floats?type=petty_cash&offset=0&limit=10
app.get('/api/floats', async (req, reply) => {
const { type, offset = 0, limit = 10 } = req.query
const validTypes = ['petty_cash', 'change_tin', 'safe_cash']
if (!validTypes.includes(type)) return reply.status(400).send({ error: 'invalid type' })
const { rows } = await pool.query(
`SELECT * FROM float_counts WHERE count_type = $1 ORDER BY count_date DESC LIMIT $2 OFFSET $3`,
[type, parseInt(limit), parseInt(offset)]
)
const { rows: countRows } = await pool.query(
`SELECT COUNT(*) FROM float_counts WHERE count_type = $1`, [type]
)
const total = parseInt(countRows[0].count)
return { rows, total, has_more: parseInt(offset) + rows.length < total }
})
// GET /api/floats/:id
app.get('/api/floats/:id', async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM float_counts WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const count = rows[0]
const [denoms, receipts] = await Promise.all([
pool.query('SELECT * FROM float_denominations WHERE float_count_id = $1 ORDER BY denomination_value DESC', [count.id]),
count.count_type === 'petty_cash'
? pool.query('SELECT * FROM float_receipts WHERE float_count_id = $1', [count.id])
: Promise.resolve({ rows: [] }),
])
// For change tin, load target amounts from settings
let changeTinBreakdown = {}
if (count.count_type === 'change_tin') {
const { rows: s } = await pool.query(`SELECT value FROM settings WHERE key = 'change_tin_breakdown'`)
try { changeTinBreakdown = JSON.parse(s[0]?.value || '{}') } catch {}
}
const denomsWithTarget = denoms.rows.map(d => ({
...d,
target: count.count_type === 'change_tin'
? parseFloat(changeTinBreakdown[parseFloat(d.denomination_value).toFixed(2)] || 0)
: 0,
}))
return { ...count, denominations: denomsWithTarget, receipts: receipts.rows }
})
}

View file

@ -0,0 +1,49 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import {
fetchPaymentsByDate,
calculatePaymentTotals,
parseTillTransactions,
parseTransactionBreakdown,
} from '../lib/newbook.js'
export async function newbookRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/newbook/payments { date: 'YYYY-MM-DD' }
app.post('/api/newbook/payments', async (req, reply) => {
const { date } = req.body
if (!date) return reply.status(400).send({ error: 'date required' })
let result
try {
result = await fetchPaymentsByDate(date, true)
} catch (err) {
return reply.status(502).send({ error: err.message })
}
const { payments, raw_data } = result
// Cache payments in DB (delete existing for date, insert fresh)
await pool.query(
`DELETE FROM payment_records WHERE DATE(payment_date) = $1`, [date]
)
for (const p of payments) {
await pool.query(
`INSERT INTO payment_records
(newbook_payment_id, booking_id, guest_name, payment_date, payment_type,
transaction_method, card_type, amount, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())`,
[p.payment_id, p.booking_id, p.guest_name, p.payment_date,
p.payment_type, p.transaction_method, p.card_type, p.amount]
)
}
return {
count: payments.length,
totals: calculatePaymentTotals(payments),
till_payments: parseTillTransactions(raw_data),
transaction_breakdown: parseTransactionBreakdown(raw_data),
}
})
}

View file

@ -0,0 +1,210 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import {
fetchPaymentsByDateRange,
fetchDailyAuditSummary,
fetchGlAccountList,
fetchEarnedRevenue,
fetchOccupancy,
fetchBookingsList,
fetchSitesList,
fetchDebtorsCreditors,
} from '../lib/newbook.js'
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + n)
return d.toISOString().slice(0, 10)
}
function buildDateRange(startDate, numDays) {
const dates = []
for (let i = 0; i < numDays; i++) dates.push(addDays(startDate, i))
return dates
}
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
// POST /api/reports/multiday { start_date, num_days }
app.post('/api/reports/multiday', async (req, reply) => {
const { start_date, num_days } = req.body
const days = parseInt(num_days)
if (!start_date || isNaN(days) || days < 1 || days > 365) {
return reply.status(400).send({ error: 'start_date and num_days (1365) required' })
}
const dates = buildDateRange(start_date, days)
const endDate = dates[dates.length - 1]
// Fetch all Newbook data in parallel
const [paymentsByDate, glAccountList, earnedRevenueRaw, occupancyData, bookingsData, sitesData] =
await Promise.allSettled([
fetchPaymentsByDateRange(start_date, endDate),
fetchGlAccountList(),
fetchEarnedRevenue(start_date, endDate, 'day'),
fetchOccupancy(start_date, endDate),
fetchBookingsList(start_date, endDate),
fetchSitesList(),
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : []))
// Build GL account → group map
const accountToGroup = {}
for (const a of (glAccountList || [])) {
if (a.gl_account_code && a.gl_group_id) accountToGroup[a.gl_account_code] = a.gl_group_id
}
// Aggregate earned revenue by GL group + period
const aggregatedRevenue = {}
for (const item of (earnedRevenueRaw || [])) {
const period = item.period || ''
const groupId = accountToGroup[item.gl_account_code] ?? item.gl_group_id ?? ''
if (!period || !groupId) continue
const key = `${period}_${groupId}`
if (!aggregatedRevenue[key]) {
aggregatedRevenue[key] = { period, gl_group_id: groupId, earned_revenue_ex: 0, earned_revenue_tax: 0, earned_revenue: 0 }
}
aggregatedRevenue[key].earned_revenue_ex += parseFloat(item.earned_revenue_ex || 0)
aggregatedRevenue[key].earned_revenue_tax += parseFloat(item.earned_revenue_tax || 0)
aggregatedRevenue[key].earned_revenue += parseFloat(item.earned_revenue || 0)
}
const earnedRevenue = Object.values(aggregatedRevenue)
// Load sales breakdown column settings
const { rows: settingsRows } = await pool.query(
`SELECT value FROM settings WHERE key = 'sales_breakdown_columns'`
)
let columnSettings = []
try { columnSettings = JSON.parse(settingsRows[0]?.value || '[]') } catch {}
if (!columnSettings.length) {
columnSettings = [
{ gl_code: 'ACCOMMODATION', display_name: 'Accommodation', enabled: true, sort_order: 1 },
{ gl_code: 'FOOD', display_name: 'Food', enabled: true, sort_order: 2 },
{ gl_code: 'BEVERAGE', display_name: 'Beverage', enabled: true, sort_order: 3 },
{ gl_code: 'OTHER', display_name: 'Other', enabled: true, sort_order: 4 },
]
}
columnSettings.sort((a, b) => a.sort_order - b.sort_order)
const enabledColumns = columnSettings.filter(c => c.enabled)
const disabledColumns = columnSettings.filter(c => !c.enabled)
// Process each date
const reportData = await Promise.all(dates.map(async (date) => {
const freshPayments = (paymentsByDate || {})[date] || []
// Payment totals
const pt = { cash: 0, gateway_visa_mc: 0, gateway_amex: 0, manual_visa_mc: 0, manual_amex: 0, bacs: 0 }
for (const p of freshPayments) {
const amt = parseFloat(p.amount)
if (p.card_type === 'other') continue
if (p.card_type === 'cash') { pt.cash += amt; continue }
if (p.card_type === 'bacs') { pt.bacs += amt; continue }
const gw = ['automated','gateway','cc_gateway'].includes(p.transaction_method)
if (p.card_type === 'amex') { gw ? (pt.gateway_amex += amt) : (pt.manual_amex += amt); continue }
if (p.card_type === 'visa_mc') { gw ? (pt.gateway_visa_mc += amt) : (pt.manual_visa_mc += amt) }
}
// Cash up record
const { rows: cashUpRows } = await pool.query(
'SELECT * FROM cash_ups WHERE session_date = $1', [date]
)
const cashUp = cashUpRows[0] || null
let bankedCash = 0, bankedPdqVisMc = 0, bankedPdqAmex = 0
if (cashUp) {
bankedCash = parseFloat(cashUp.total_cash_counted)
const { rows: machines } = await pool.query(
'SELECT * FROM card_machines WHERE cash_up_id = $1', [cashUp.id]
)
for (const m of machines) {
bankedPdqVisMc += parseFloat(m.visa_mc_amount)
bankedPdqAmex += parseFloat(m.amex_amount)
}
}
const reconciliation = [
{ category: 'cash', banked_amount: bankedCash, reported_amount: pt.cash },
{ category: 'gateway_visa_mc', banked_amount: pt.gateway_visa_mc, reported_amount: pt.gateway_visa_mc },
{ category: 'gateway_amex', banked_amount: pt.gateway_amex, reported_amount: pt.gateway_amex },
{ category: 'pdq_visa_mc', banked_amount: bankedPdqVisMc, reported_amount: pt.manual_visa_mc },
{ category: 'pdq_amex', banked_amount: bankedPdqAmex, reported_amount: pt.manual_amex },
{ category: 'bacs', banked_amount: pt.bacs, reported_amount: pt.bacs },
]
// Sales breakdown from earned revenue
let displayedGross = 0
const salesBreakdown = enabledColumns.map(col => {
const item = earnedRevenue.find(r =>
r.period === date && r.gl_group_id.toUpperCase() === col.gl_code.toUpperCase()
)
const net = parseFloat(item?.earned_revenue_ex || 0)
const vat = parseFloat(item?.earned_revenue_tax || 0)
const gross = parseFloat(item?.earned_revenue || 0)
displayedGross += gross
return { gl_code: col.gl_code, category: col.display_name, net_amount: net, vat_amount: vat, gross_amount: gross }
})
// Daily stats from payments
const grossSales = freshPayments.reduce((s, p) => s + parseFloat(p.amount), 0)
const dailyStats = grossSales > 0
? { business_date: date, gross_sales: grossSales, transaction_count: freshPayments.length }
: null
return { date, cash_up: cashUp, reconciliation, daily_stats: dailyStats, sales_breakdown: salesBreakdown }
}))
return {
report_data: reportData,
gl_accounts: glAccountList || [],
earned_revenue: earnedRevenue,
occupancy_data: occupancyData || [],
bookings_data: bookingsData || [],
sites_data: sitesData || [],
sales_columns: enabledColumns,
disabled_columns: disabledColumns,
// Debtors/creditors loaded lazily by the client via /api/reports/debtors-creditors
balances_loading: true,
}
})
// POST /api/reports/debtors-creditors { start_date, num_days }
app.post('/api/reports/debtors-creditors', async (req, reply) => {
const { start_date, num_days } = req.body
const days = parseInt(num_days)
if (!start_date || isNaN(days) || days < 1 || days > 365) {
return reply.status(400).send({ error: 'start_date and num_days required' })
}
const dates = buildDateRange(start_date, days)
const dayBefore = addDays(start_date, -1)
const [periodOpen, ...dateBalances] = await Promise.all([
fetchDebtorsCreditors(dayBefore).catch(() => ({ creditors: 0, debtors: 0, overall: 0, accounts: [] })),
...dates.map(d => fetchDebtorsCreditors(d).catch(() => ({ creditors: 0, debtors: 0, overall: 0, accounts: [] }))),
])
const balancesByDate = Object.fromEntries(dates.map((d, i) => [d, dateBalances[i]]))
return { period_open_balance: periodOpen, balances_by_date: balancesByDate }
})
// GET /api/reports/cash-summary?from=YYYY-MM-DD&to=YYYY-MM-DD
app.get('/api/reports/cash-summary', async (req, reply) => {
const { from, to } = req.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const { rows } = await pool.query(
`SELECT d.denomination_value, SUM(d.quantity) AS total_quantity, SUM(d.total_amount) AS total_value
FROM denominations d
JOIN cash_ups c ON d.cash_up_id = c.id
WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings'
GROUP BY d.denomination_value
ORDER BY d.denomination_value DESC`,
[from, to]
)
return {
denominations: rows,
period: { from, to },
}
})
}

View file

@ -0,0 +1,86 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
'default_report_days', 'petty_cash_float',
'sales_breakdown_columns', 'change_tin_breakdown',
]
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/settings
app.get('/api/settings', async () => {
const { rows } = await pool.query(`SELECT key, value FROM settings WHERE key = ANY($1)`, [ALL_KEYS])
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/settings (admin only)
app.put('/api/settings', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
for (const [key, value] of Object.entries(req.body)) {
if (!ALL_KEYS.includes(key)) continue
await pool.query(
`INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[key, typeof value === 'object' ? JSON.stringify(value) : String(value)]
)
}
return { message: 'Settings saved.' }
})
// POST /api/settings/test-connection (admin only) — proxies to settings service
app.post('/api/settings/test-connection', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
const result = await testConnection()
return result
})
// POST /api/settings/refresh-gl-accounts (admin only)
app.post('/api/settings/refresh-gl-accounts', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
let groups
try {
groups = await fetchGlAccountsGrouped()
} catch (err) {
return reply.status(502).send({ error: err.message })
}
const { rows: s } = await pool.query(`SELECT value FROM settings WHERE key = 'sales_breakdown_columns'`)
let existing = []
try { existing = JSON.parse(s[0]?.value || '[]') } catch {}
const existingMap = Object.fromEntries(existing.map(c => [c.gl_code, c]))
const updated = []
let newCount = 0, removedCount = 0, updatedCount = 0
for (const col of existing) {
if (groups[col.gl_code] !== undefined) {
updated.push({ ...col, display_name: groups[col.gl_code] })
updatedCount++
delete groups[col.gl_code]
} else {
removedCount++
}
}
let maxOrder = updated.length ? Math.max(...updated.map(c => c.sort_order)) : 0
for (const [glCode, displayName] of Object.entries(groups)) {
updated.push({ gl_code: glCode, display_name: displayName, enabled: false, sort_order: ++maxOrder })
newCount++
}
await pool.query(
`INSERT INTO settings (key, value) VALUES ('sales_breakdown_columns', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[JSON.stringify(updated)]
)
const parts = []
if (newCount) parts.push(`Added ${newCount}`)
if (removedCount) parts.push(`Removed ${removedCount}`)
if (updatedCount) parts.push(`Updated ${updatedCount}`)
return { message: parts.length ? parts.join(', ') + ' GL account(s).' : 'No changes.', columns: updated }
})
}