cashup/backend/src/routes/cashup.js
jtricerolph be670f724d Enforce granular capabilities across cashup
Backend (server-side enforcement, not just UI):
- auth.js: read caps from JWT; hasCap() + requireCap() helpers;
  legacy-token fallback (full access minus settings) so existing
  sessions keep working until re-login
- finalise: submit final, delete draft, bulk-finalise, attachments
- reports: multiday report, cash summary, debtors
- floats: float management + safe count
- settings: settings mutations (was is_admin)
- count: draft save, newbook fetch

Frontend:
- can(user, cap) helper; User.caps from /verify
- Nav items, routes and actions (Submit Final, delete, bulk-finalise)
  gated on capabilities; non-finalisers see a draft-only hint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:19:30 +00:00

162 lines
6.8 KiB
JavaScript

import { pool } from '../db.js'
import { requireAuth, hasCap } 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' })
// Draft save needs 'count'; finalising needs 'finalise'.
if (!hasCap(req, 'count')) return reply.status(403).send({ error: 'Missing capability: count' })
if (status === 'final' && !hasCap(req, 'finalise')) {
return reply.status(403).send({ error: 'Missing capability: finalise' })
}
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 (requires cashup:finalise)
app.delete('/api/cashup/:id', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
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 (requires cashup:finalise)
app.post('/api/cashup/bulk-finalize', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
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) }
})
}