Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
35
backend/src/auth.js
Normal file
35
backend/src/auth.js
Normal 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
141
backend/src/db.js
Normal 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
99
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
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 }
|
||||
}
|
||||
154
backend/src/routes/cashup.js
Normal file
154
backend/src/routes/cashup.js
Normal 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) }
|
||||
})
|
||||
}
|
||||
95
backend/src/routes/floats.js
Normal file
95
backend/src/routes/floats.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
49
backend/src/routes/newbook.js
Normal file
49
backend/src/routes/newbook.js
Normal 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
210
backend/src/routes/reports.js
Normal file
210
backend/src/routes/reports.js
Normal 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 (1–365) 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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
86
backend/src/routes/settings.js
Normal file
86
backend/src/routes/settings.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue