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

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