commit 63a5a72fa34d2e55628520621c3ae848eef52eeb Author: jtricerolph Date: Wed Jul 1 19:33:16 2026 +0000 Wire Newbook credentials to settings service diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7125b25 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +RUN mkdir -p /app/uploads +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..862721d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "hnf-cashup-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "@fastify/multipart": "^9.0.1", + "@fastify/static": "^8.0.1", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "pg": "^8.13.1" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..d358875 --- /dev/null +++ b/backend/src/auth.js @@ -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, + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..ded8924 --- /dev/null +++ b/backend/src/db.js @@ -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 + `) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..285ec74 --- /dev/null +++ b/backend/src/index.js @@ -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) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -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 +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 0000000..89dd9cd --- /dev/null +++ b/backend/src/lib/newbook.js @@ -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 } +} diff --git a/backend/src/routes/cashup.js b/backend/src/routes/cashup.js new file mode 100644 index 0000000..3638f0e --- /dev/null +++ b/backend/src/routes/cashup.js @@ -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) } + }) +} diff --git a/backend/src/routes/floats.js b/backend/src/routes/floats.js new file mode 100644 index 0000000..24e06bb --- /dev/null +++ b/backend/src/routes/floats.js @@ -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 } + }) +} diff --git a/backend/src/routes/newbook.js b/backend/src/routes/newbook.js new file mode 100644 index 0000000..269529c --- /dev/null +++ b/backend/src/routes/newbook.js @@ -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), + } + }) +} diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js new file mode 100644 index 0000000..ae74141 --- /dev/null +++ b/backend/src/routes/reports.js @@ -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 }, + } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..a2e3542 --- /dev/null +++ b/backend/src/routes/settings.js @@ -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 } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..df94b8a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + backend: + build: ./backend + security_opt: + - apparmor=unconfined + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - APP_SLUG=cashup + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + volumes: + - uploads_data:/app/uploads + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 20s + restart: unless-stopped + + frontend: + build: ./frontend + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3083}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +volumes: + uploads_data: + +networks: + default: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4641cba --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +ARG VITE_HOTEL_NAME="Number Four at Stow" +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/cashup +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9d46743 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Cash Up + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..99d50c9 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,38 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + location = /cashup/manifest.json { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location /cashup/api/ { + proxy_pass http://backend:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + client_max_body_size 15M; + } + + location /cashup/health { + proxy_pass http://backend:3001/health; + } + + location ~* /cashup/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /cashup/ { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /cashup/index.html; + } + + location = / { + return 301 /cashup/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8680540 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-cashup-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.23.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..01d23b8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,37 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthGate } from './components/AuthGate' +import { Layout } from './components/Layout' +import { DailyCashUp } from './pages/DailyCashUp' +import { History } from './pages/History' +import { MultiDayReport } from './pages/MultiDayReport' +import { FloatManagement } from './pages/FloatManagement' +import { CashSummary } from './pages/CashSummary' +import { SettingsPage } from './pages/Settings' +import type { User } from './types' + +function AppRoutes({ user }: { user: User }) { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default function App() { + return ( + + + {user => } + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..4304c41 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,37 @@ +const BASE = '/cashup/api' + +async function req(method: string, path: string, body?: unknown): Promise { + const res = await fetch(BASE + path, { + method, + credentials: 'include', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((err as { error?: string }).error || res.statusText) + } + return res.json() +} + +export const api = { + get: (path: string) => req('GET', path), + post: (path: string, body: unknown) => req('POST', path, body), + put: (path: string, body: unknown) => req('PUT', path, body), + delete: (path: string) => req('DELETE', path), +} + +export async function uploadAttachment(cashUpId: number, file: File) { + const fd = new FormData() + fd.append('file', file) + const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, { + method: 'POST', + credentials: 'include', + body: fd, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((err as { error?: string }).error || res.statusText) + } + return res.json() +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..8929ae3 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import type { User } from '../types' + +interface Props { + children: (user: User) => React.ReactNode +} + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} +const btnStyle: React.CSSProperties = { + background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none', + borderRadius: '6px', padding: '0.625rem', fontSize: '1rem', + fontWeight: 600, marginTop: '0.25rem', +} + +export function AuthGate({ children }: Props) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') + const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetch('/cashup/api/auth/verify?app=cashup', { credentials: 'include' }) + .then(async r => { + if (r.ok) { setUser(await r.json()); setState('authed') } + else setState('login') + }) + .catch(() => setState('login')) + }, []) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/cashup/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/cashup/api/auth/verify?app=cashup', { credentials: 'include' }) + if (verify.ok) { setUser(await verify.json()); setState('authed') } + else setError("You don't have access to this app.") + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { + return ( +
+
Loading…
+
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ Cash Up +

+

+ {import.meta.env.VITE_HOTEL_NAME} +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" style={inputStyle} /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" style={inputStyle} /> + {error &&

{error}

} + +
+
+
+ ) + } + + return <>{children(user!)} +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..6b58131 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,154 @@ +import { NavLink, useNavigate } from 'react-router-dom' +import { + Banknote, ClipboardList, BarChart2, Wallet, FileText, Settings, LogOut, ChevronRight, +} from 'lucide-react' +import type { User } from '../types' + +interface Props { + user: User + children: React.ReactNode +} + +const navItems = [ + { to: '/daily', label: 'Daily Cash Up', icon: Banknote }, + { to: '/history', label: 'History', icon: ClipboardList }, + { to: '/report', label: 'Weekly Report', icon: BarChart2 }, + { to: '/floats', label: 'Float Management', icon: Wallet }, + { to: '/summary', label: 'Cash Summary', icon: FileText }, + { to: '/settings',label: 'Settings', icon: Settings }, +] + +export function Layout({ user, children }: Props) { + const navigate = useNavigate() + + async function logout() { + await fetch('/cashup/api/auth/logout', { method: 'POST', credentials: 'include' }) + navigate('/login', { replace: true }) + window.location.reload() + } + + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ {children} +
+
+ ) +} + +export function PageHeader({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ) +} + +export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { + return ( +
+ {children} +
+ ) +} + +export function Btn({ + children, onClick, variant = 'primary', disabled, small, type = 'button', style, +}: { + children: React.ReactNode + onClick?: () => void + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' + disabled?: boolean + small?: boolean + type?: 'button' | 'submit' + style?: React.CSSProperties +}) { + const styles: Record = { + primary: { background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none' }, + secondary: { background: 'var(--card-bg)', color: 'var(--text-dark)', border: '1px solid var(--card-border)' }, + danger: { background: 'var(--danger)', color: '#fff', border: 'none' }, + ghost: { background: 'transparent', color: 'var(--text-mid)', border: '1px solid var(--card-border)' }, + } + return ( + + ) +} + +export function StatusBadge({ status }: { status: 'draft' | 'final' }) { + const styles = { + draft: { background: '#fef9c3', color: '#ca8a04' }, + final: { background: '#dcfce7', color: '#16a34a' }, + } + return ( + + {status} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a11911d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,36 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --navy: #1a1a2e; + --navy-dark: #0f0f20; + --gold: #c9a84c; + --gold-light: #e8c96d; + --surface: rgba(255,255,255,0.07); + --surface-2: rgba(255,255,255,0.08); + --text: rgba(255,255,255,0.88); + --text-muted: rgba(255,255,255,0.48); + + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --card-border: #e4e8ee; + --text-dark: #1e293b; + --text-mid: #64748b; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + + --danger: #dc2626; + --success: #16a34a; + --warning: #d97706; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +body { + background: var(--body-bg); + color: var(--text-dark); + font-family: var(--font); + min-height: 100dvh; +} + +button { cursor: pointer; font-family: inherit; } +input, textarea, select { font-family: inherit; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..023913e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/CashSummary.tsx b/frontend/src/pages/CashSummary.tsx new file mode 100644 index 0000000..8b69dfe --- /dev/null +++ b/frontend/src/pages/CashSummary.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react' +import { api } from '../api' +import { PageHeader, Card, Btn } from '../components/Layout' +import { GBP_DENOMINATIONS, fmtGBP, today } from '../types' + +interface DenomRow { denomination_value: string; total_quantity: string; total_value: string } +interface SummaryResult { denominations: DenomRow[]; period: { from: string; to: string } } + +export function CashSummary() { + const [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) }) + const [to, setTo] = useState(today) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function generate() { + setLoading(true); setError(''); setResult(null) + try { + const data = await api.get(`/reports/cash-summary?from=${from}&to=${to}`) + setResult(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to generate summary') + } finally { + setLoading(false) + } + } + + const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0 + + return ( +
+ + + +
+ + setFrom(e.target.value)} + style={inpSt} /> +
+
+ + setTo(e.target.value)} + style={inpSt} /> +
+ {loading ? 'Loading…' : 'Generate'} +
+ + {error && ( +
+ {error} +
+ )} + + {result && ( + +

+ {result.period.from} → {result.period.to} +

+

+ Takings only (excludes float counts) +

+ + + + + + + + + + + {GBP_DENOMINATIONS.map(d => { + const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001) + if (!row) return null + return ( + + + + + + ) + })} + + + + + + + + +
DenominationTotal QtyTotal Value
{d.label}{row.total_quantity}{fmtGBP(row.total_value)}
Grand Total{fmtGBP(grandTotal)}
+
+ )} +
+ ) +} + +const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' } +const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' } diff --git a/frontend/src/pages/DailyCashUp.tsx b/frontend/src/pages/DailyCashUp.tsx new file mode 100644 index 0000000..c69a686 --- /dev/null +++ b/frontend/src/pages/DailyCashUp.tsx @@ -0,0 +1,455 @@ +import { useState, useCallback } from 'react' +import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react' +import { api } from '../api' +import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' +import { + GBP_DENOMINATIONS, fmtGBP, today, + type User, type CashUp, type Denomination, type CardMachine, + type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment, +} from '../types' + +const MACHINES = ['Front Desk', 'Restaurant / Bar'] + +function initDenominations(countType: 'takings' | 'float'): Denomination[] { + return GBP_DENOMINATIONS.map(d => ({ + count_type: countType, + denomination_type: d.type, + denomination_value: d.value, + quantity: null, + value_entered: null, + total_amount: 0, + })) +} + +function initMachines(): CardMachine[] { + return MACHINES.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 })) +} + +function denomTotal(denoms: Denomination[]) { + return denoms.reduce((s, d) => s + d.total_amount, 0) +} + +interface Props { user: User } + +export function DailyCashUp({ user: _user }: Props) { + const [date, setDate] = useState(today()) + const [cashUp, setCashUp] = useState(null) + const [takings, setTakings] = useState(initDenominations('takings')) + const [float, setFloat] = useState(initDenominations('float')) + const [machines, setMachines] = useState(initMachines()) + const [notes, setNotes] = useState('') + const [attachments, setAttachments] = useState([]) + const [newbookTotals, setNewbookTotals] = useState(null) + const [tillPayments, setTillPayments] = useState([]) + const [fetching, setFetching] = useState(false) + const [saving, setSaving] = useState(false) + const [loading, setLoading] = useState(false) + const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) + const [showFloat, setShowFloat] = useState(false) + + const isFinal = cashUp?.status === 'final' + + function flash(text: string, ok = true) { + setMsg({ text, ok }) + setTimeout(() => setMsg(null), 4000) + } + + async function loadExisting() { + setLoading(true) + try { + const data = await api.get<{ + cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; + reconciliation: ReconciliationRow[]; attachments: Attachment[] + }>(`/cashup?date=${date}`) + + setCashUp(data.cash_up) + setNotes(data.cash_up.notes || '') + setAttachments(data.attachments || []) + + // Rebuild denomination grids from saved data + const rebuild = (ct: 'takings' | 'float') => + GBP_DENOMINATIONS.map(d => { + const saved = data.denominations.find( + s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value + ) + return saved + ? { ...saved, denomination_value: d.value } + : { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 } + }) + + setTakings(rebuild('takings')) + setFloat(rebuild('float')) + + if (data.card_machines.length) { + setMachines(MACHINES.map(name => { + const m = data.card_machines.find(c => c.machine_name === name) + return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 } + })) + } + + flash('Loaded existing cash up.') + } catch (e: unknown) { + if (e instanceof Error && e.message === 'Not found') flash('No cash up for this date.', false) + else flash('Failed to load.', false) + } finally { + setLoading(false) + } + } + + function reset() { + setCashUp(null) + setTakings(initDenominations('takings')) + setFloat(initDenominations('float')) + setMachines(initMachines()) + setNotes('') + setAttachments([]) + setNewbookTotals(null) + setTillPayments([]) + } + + function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) { + const val = raw === '' ? null : parseFloat(raw) + const updated = list.map((d, i) => { + if (i !== idx) return d + if (field === 'quantity') { + const qty = val === null ? null : Math.max(0, Math.round(val)) + return { ...d, quantity: qty, value_entered: null, total_amount: qty === null ? 0 : qty * d.denomination_value } + } else { + const ve = val === null ? null : Math.max(0, val) + return { ...d, value_entered: ve, quantity: null, total_amount: ve ?? 0 } + } + }) + setList(updated) + } + + function updateMachine(idx: number, field: 'total_amount' | 'amex_amount', raw: string) { + const val = raw === '' ? 0 : parseFloat(raw) || 0 + setMachines(machines.map((m, i) => { + if (i !== idx) return m + const total = field === 'total_amount' ? val : m.total_amount + const amex = field === 'amex_amount' ? val : m.amex_amount + return { ...m, total_amount: total, amex_amount: amex, visa_mc_amount: Math.max(0, total - amex) } + })) + } + + async function fetchNewbook() { + setFetching(true) + try { + const data = await api.post<{ + count: number; totals: PaymentTotals; till_payments: TillPayment[] + }>('/newbook/payments', { date }) + setNewbookTotals(data.totals) + setTillPayments(data.till_payments || []) + flash(`Fetched ${data.count} payment(s) from Newbook.`) + } catch (e: unknown) { + flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false) + } finally { + setFetching(false) + } + } + + async function save(status: 'draft' | 'final') { + setSaving(true) + try { + const allDenoms = [...takings, ...float].filter(d => d.total_amount > 0) + const result = await api.post<{ cash_up_id: number; message: string }>('/cashup/save', { + session_date: date, + status, + notes, + denominations: allDenoms.map(d => ({ + count_type: d.count_type, + type: d.denomination_type, + value: d.denomination_value, + quantity: d.quantity, + value_entered: d.value_entered, + total_amount: d.total_amount, + })), + card_machines: machines.map(m => ({ + name: m.machine_name, + total: m.total_amount, + amex: m.amex_amount, + visa_mc: m.visa_mc_amount, + })), + }) + flash(result.message) + if (status === 'final') { + setCashUp(prev => prev ? { ...prev, status: 'final' } : null) + } + } catch (e: unknown) { + flash(e instanceof Error ? e.message : 'Save failed.', false) + } finally { + setSaving(false) + } + } + + // Build reconciliation rows from local + Newbook data + const recon: ReconciliationRow[] = newbookTotals ? [ + { category: 'Cash', banked_amount: denomTotal(takings), reported_amount: newbookTotals.cash }, + { category: 'PDQ Visa/MC', banked_amount: machines.reduce((s, m) => s + m.visa_mc_amount, 0), reported_amount: newbookTotals.manual_visa_mc }, + { category: 'PDQ Amex', banked_amount: machines.reduce((s, m) => s + m.amex_amount, 0), reported_amount: newbookTotals.manual_amex }, + { category: 'Gateway Visa/MC', banked_amount: newbookTotals.gateway_visa_mc, reported_amount: newbookTotals.gateway_visa_mc }, + { category: 'Gateway Amex', banked_amount: newbookTotals.gateway_amex, reported_amount: newbookTotals.gateway_amex }, + { category: 'BACS', banked_amount: newbookTotals.bacs, reported_amount: newbookTotals.bacs }, + ] : [] + + const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0) + + return ( +
+ + {cashUp &&
} + + {msg && ( +
+ {msg.text} +
+ )} + + {/* Date selector */} + +
+ + { setDate(e.target.value); reset() }} + style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }} + /> +
+
+ + {loading ? 'Loading…' : 'Load Existing'} + + {cashUp && New} +
+
+ + {/* Cash denomination — Takings */} + +
+

Cash Takings

+ {fmtGBP(denomTotal(takings))} +
+ updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} /> +
+ + {/* Float (collapsible) */} + + + {showFloat && ( +
+ updateDenom(float, setFloat, i, f, v)} disabled={isFinal} /> +
+ )} +
+ + {/* Card Machines */} + +
+

Card Machines (PDQ)

+ Total: {fmtGBP(totalPdq)} +
+
+ {machines.map((m, i) => ( +
+

+ {m.machine_name} +

+
+ updateMachine(i, 'total_amount', v)} disabled={isFinal} /> + updateMachine(i, 'amex_amount', v)} disabled={isFinal} /> +
+ Visa / MC + {fmtGBP(m.visa_mc_amount)} +
+
+
+ ))} +
+
+ + {/* Newbook + Reconciliation */} + +
+

Newbook Reconciliation

+ + {fetching ? <> Fetching… : <> Fetch Payments} + +
+ + {newbookTotals && ( + <> + + + + {['Category', 'Banked', 'Reported', 'Variance'].map(h => ( + + ))} + + + + {recon.map(row => { + const variance = row.banked_amount - row.reported_amount + const varColor = Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)' + return ( + + + + + + + ) + })} + +
{h}
{row.category}{fmtGBP(row.banked_amount)}{fmtGBP(row.reported_amount)} + {Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))} +
+ + {tillPayments.length > 0 && ( +
+

+ Till / Restaurant Transactions +

+ + + + + + + + + + {tillPayments.map(t => ( + + + + + + ))} + +
TypeQtyTotal
{t.payment_type}{t.quantity}{fmtGBP(t.total_value)}
+
+ )} + + )} +
+ + {/* Notes */} + + +