BillTracker/routes/payments.js

611 lines
27 KiB
JavaScript

const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService');
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
// values are). Interpolating a hardcoded constant like this is safe by
// construction; do NOT replace this pattern with dynamic/user-controlled input.
const SQL_NOT_DELETED = 'deleted_at IS NULL';
const TRANSACTION_MATCH_SOURCE = 'transaction_match';
function isTransactionLinkedPayment(payment) {
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
}
function rejectTransactionLinkedPayment(res) {
return res.status(409).json(standardizeError(
'Transaction-linked payments must be changed through transaction match controls',
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id',
));
}
function parseYearMonth(body) {
const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { error: standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year') };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { error: standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month') };
}
return { year, month };
}
function getAutopaySuggestionContext(db, userId, billId, year, month) {
const bill = db.prepare(`
SELECT *
FROM bills
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
`).get(billId, userId);
if (!bill) return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
return { error: standardizeError('Bill is not eligible for autopay suggestions', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
}
const state = db.prepare(`
SELECT actual_amount, is_skipped
FROM monthly_bill_state
WHERE bill_id = ? AND year = ? AND month = ?
`).get(bill.id, year, month);
if (state?.is_skipped) {
return { error: standardizeError('Skipped bills cannot be suggested for payment', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
}
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) {
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 };
}
const amount = state?.actual_amount ?? bill.expected_amount;
return { bill, dueDate, amount };
}
// 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.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`;
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/recent-auto — provider_sync payments with a linked tx, last 7 days
router.get('/recent-auto', (req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.payment_source,
p.transaction_id, p.balance_delta, p.interest_delta, p.created_at,
b.name AS bill_name,
t.payee, t.description, t.amount AS tx_cents, t.posted_date
FROM payments p
JOIN bills b ON b.id = p.bill_id
LEFT JOIN transactions t ON t.id = p.transaction_id
WHERE b.user_id = ?
AND p.payment_source = 'provider_sync'
AND p.transaction_id IS NOT NULL
AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
AND p.created_at >= datetime('now', '-7 days')
ORDER BY p.created_at DESC
LIMIT 50
`).all(req.user.id);
res.json(rows);
});
// 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.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).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/:id/undo-auto — reverse a provider_sync auto-match
router.post('/:id/undo-auto', (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.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (payment.payment_source !== 'provider_sync') {
return res.status(409).json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH'));
}
if (!payment.transaction_id) {
return res.status(409).json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
}
try {
db.transaction(() => {
// Restore balance (same logic as DELETE /:id)
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
db.prepare(`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
db.prepare(`
UPDATE transactions
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(payment.transaction_id, req.user.id);
})();
res.json({ success: true });
} catch (err) {
console.error('[payments] undo-auto error:', err.message);
res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR'));
}
});
// POST /api/payments — create single payment
router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized;
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source);
applyBalanceDelta(db, bill.id, balCalc);
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, payment_source } = req.body;
const billValidation = validatePaymentInput({ bill_id }, { requireAmount: false, requirePaidDate: false });
if (billValidation.error) {
return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billValidation.normalized.bill_id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const paymentValidation = validatePaymentInput(
{
amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || new Date().toISOString().slice(0, 10),
payment_source: payment_source ?? 'manual',
},
{ requireBillId: false },
);
if (paymentValidation.error) {
return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source;
const balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource);
applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
// POST /api/payments/autopay-suggestions/:billId/confirm
router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
if (context.error) return res.status(context.status).json(context.error);
const { bill, dueDate, amount } = context;
if (dueDate > new Date().toISOString().slice(0, 10)) {
return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
}
const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate },
{ requireBillId: false },
);
if (paymentValidation.error) {
return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const suggestedPayment = paymentValidation.normalized;
const suggestionRange = getCycleRange(ym.year, ym.month, bill);
const existing = db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.bill_id = ?
AND b.user_id = ?
AND p.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ?
ORDER BY p.paid_date DESC
LIMIT 1
`).get(bill.id, req.user.id, suggestionRange.start, suggestionRange.end);
if (existing) {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
return res.json({ created: false, payment: existing });
}
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
const result = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedPayment.amount,
suggestedPayment.paid_date,
'autopay',
'Confirmed autopay suggestion',
balCalc?.balance_delta ?? null,
balCalc?.interest_delta ?? null,
'manual',
);
applyBalanceDelta(db, bill.id, balCalc);
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
router.post('/autopay-suggestions/:billId/dismiss', (req, res) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
db.prepare(`
INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, bill_id, year, month)
DO UPDATE SET dismissed_at = datetime('now')
`).run(req.user.id, billId, ym.year, ym.month);
res.json({ success: true });
});
// POST /api/payments/bulk — record multiple payments in one request
// Bulk payment creation endpoint
// Validation rules:
// - Request body must contain a `payments` array
// - Maximum 50 items per request
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] }
router.post('/bulk', (req, res) => {
const db = getDb();
const { payments } = req.body;
// Validate request body has payments array
if (!payments || !Array.isArray(payments))
return res.status(400).json(standardizeError('Request body must contain a `payments` array', 'VALIDATION_ERROR', 'payments'));
// Validate max items per request (50)
if (payments.length > 50)
return res.status(400).json(standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'));
// Validate each payment item
for (let i = 0; i < payments.length; i++) {
const item = payments[i];
const validation = validatePaymentInput(
{ ...item, payment_source: item.payment_source ?? 'manual' },
{ fieldPrefix: `payments[${i}].` },
);
if (validation.error) {
return res.status(400).json(standardizeError(`Payment at index ${i}: ${validation.error}`, 'VALIDATION_ERROR', validation.field));
}
}
const insert = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
// Prepare statement for duplicate checking
const duplicateCheckStmt = db.prepare(
`SELECT 1 FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND p.bill_id = ?
AND p.paid_date = ?
AND p.amount = ?
AND p.${SQL_NOT_DELETED}`
);
const created = [];
const skipped = [];
const errors = [];
const runBulk = db.transaction(() => {
for (const item of payments) {
const payment = validatePaymentInput({ ...item, payment_source: item.payment_source ?? 'manual' }).normalized;
const { bill_id, amount: parsedAmt, paid_date, payment_source } = payment;
const { method, notes } = item;
// Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
if (isDuplicate) {
skipped.push({ bill_id, paid_date, amount: parsedAmt });
continue;
}
const billRow = getBillForBalance.get(bill_id, req.user.id);
if (!billRow) {
errors.push({ item, error: `Bill ${bill_id} not found` });
continue;
}
const balCalc = computeBalanceDelta(billRow, parsedAmt);
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source);
applyBalanceDelta(db, bill_id, balCalc);
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
}
});
runBulk();
res.status(201).json({ created, skipped, 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.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput(
{ amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
);
if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
const nextPaymentSource = validation.normalized.payment_source ?? existing.payment_source ?? 'manual';
let nextBalanceDelta = existing.balance_delta;
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
let nextInterestDelta = existing.interest_delta ?? null;
if (bill) {
// Reverse only the *payment* portion of the stored delta (not the interest component)
// so that interest already charged this month is not double-counted. For legacy rows
// where interest_delta is NULL, fall back to reversing the full delta as before.
const interestPortion = existing.interest_delta ?? 0;
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
let restoredBalance = bill.current_balance;
if (paymentPortion != null && bill.current_balance != null) {
restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100);
}
// interest_accrued_month is still set to this month (if interest was charged) so
// computeBalanceDelta will skip interest when the payment is within the same month,
// and charge a fresh month of interest if editing into a new calendar month.
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
nextBalanceDelta = balCalc?.balance_delta ?? null;
nextInterestDelta = balCalc?.interest_delta ?? null;
if (balCalc) {
applyBalanceDelta(db, existing.bill_id, balCalc);
} else if (paymentPortion != null && restoredBalance != null) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(restoredBalance, existing.bill_id);
}
}
db.prepare(`
UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
payment_source = ?, updated_at = datetime('now')
WHERE id = ?
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
`).run(
nextAmount,
nextPaidDate,
method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes,
nextBalanceDelta,
nextInterestDelta,
nextPaymentSource,
req.params.id,
req.user.id,
);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/: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.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Reverse any balance delta that was stored when this payment was created.
// If this payment was the one that charged interest this month, clear
// interest_accrued_month so the next payment can re-accrue correctly.
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
db.prepare(`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.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.* 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 = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Re-apply the balance delta (undo the reversal done on delete).
// If this payment originally charged interest, restore interest_accrued_month
// to the month of the payment so future same-month payments skip interest.
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
db.prepare(`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
}
}
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
// PATCH /api/payments/:id/attribute-to-month
// Changes only the paid_date of a provider_sync payment to move it into the
// correct billing period when it posted just after month end.
// Does not touch the amount or balance_delta.
router.patch('/:id/attribute-to-month', (req, res) => {
const db = getDb();
const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) {
return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR'));
}
const { paid_date } = req.body;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
}
// Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'));
}
try {
const payment = db.prepare(`
SELECT p.* FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
`).get(paymentId, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND'));
// Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
return res.status(409).json(standardizeError(
'Only bank-synced payments can be reclassified to a different month',
'RECLASSIFY_ONLY_SYNC',
));
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
return res.status(400).json(standardizeError(
'The new paid_date must be in the month immediately before the original payment date',
'VALIDATION_ERROR', 'paid_date',
));
}
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?")
.run(paid_date, paymentId);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id));
} catch (err) {
console.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
}
});
module.exports = router;