BillTracker/routes/payments.js

188 lines
7.9 KiB
JavaScript

const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
// GET /api/payments?bill_id=&year=&month=
router.get('/', (req, res) => {
const db = getDb();
const { bill_id, year, month } = req.query;
// Validate year/month when provided
if ((year || month) && !(year && month)) {
return res.status(400).json(standardizeError('Both year and month are required when filtering by date', 'VALIDATION_ERROR', 'year'));
}
let y, m;
if (year && month) {
y = parseInt(year, 10);
m = parseInt(month, 10);
if (!Number.isInteger(y) || y < 2000 || y > 2100) {
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
}
if (!Number.isInteger(m) || m < 1 || m > 12) {
return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'));
}
}
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ?`;
const params = [req.user.id];
if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); }
if (y && m) {
const yStr = String(y);
const mStr = String(m).padStart(2, '0');
const daysInMonth = new Date(y, m, 0).getDate();
const endDay = String(daysInMonth).padStart(2, '0');
query += ' AND p.paid_date BETWEEN ? AND ?';
params.push(`${yStr}-${mStr}-01`, `${yStr}-${mStr}-${endDay}`);
}
query += ' ORDER BY p.paid_date DESC';
res.json(db.prepare(query).all(...params));
});
// GET /api/payments/:id
router.get('/:id', (req, res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(payment);
});
// POST /api/payments — create single payment
router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
if (!bill_id || amount == null || !paid_date)
return res.status(400).json(standardizeError('bill_id, amount, and paid_date are required', 'VALIDATION_ERROR', 'bill_id'));
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0)
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(bill_id, parsedAmount, paid_date, method || null, notes || null);
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
// POST /api/payments/quick — pay a bill (expected amount, today)
router.post('/quick', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
if (!bill_id) return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const payAmount = amount != null ? parseFloat(amount) : bill.expected_amount;
if (isNaN(payAmount) || payAmount <= 0)
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
const payDate = paid_date || new Date().toISOString().slice(0, 10);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(bill_id, payAmount, payDate, method || null, notes || null);
if (bill.autopay_enabled) {
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
}
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
// POST /api/payments/bulk — record multiple payments in one request
router.post('/bulk', (req, res) => {
const db = getDb();
const items = req.body;
if (!Array.isArray(items) || items.length === 0)
return res.status(400).json(standardizeError('Body must be a non-empty array of payments', 'VALIDATION_ERROR', 'body'));
const insert = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
);
const created = [];
const errors = [];
const runBulk = db.transaction(() => {
for (const item of items) {
const { bill_id, amount, paid_date, method, notes } = item;
if (!bill_id || amount == null || !paid_date) {
errors.push({ item, error: standardizeError('bill_id, amount, and paid_date are required', 'VALIDATION_ERROR', 'bill_id') });
continue;
}
const parsedAmt = parseFloat(amount);
if (isNaN(parsedAmt) || parsedAmt <= 0) {
errors.push({ item, error: standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount') });
continue;
}
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) {
errors.push({ item, error: `Bill ${bill_id} not found` });
continue;
}
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null);
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
}
});
runBulk();
res.status(201).json({ created, errors });
});
// PUT /api/payments/:id
router.put('/:id', (req, res) => {
const db = getDb();
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
const { amount, paid_date, method, notes } = req.body;
db.prepare(`
UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
amount != null ? parseFloat(amount) : existing.amount,
paid_date ?? existing.paid_date,
method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes,
req.params.id,
);
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req, res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
// POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req, res) => {
const db = getDb();
const payment = db.prepare('SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
});
module.exports = router;