BillTracker/services/amountSuggestionService.js

138 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const { accountingActiveSql } = require('./paymentAccountingService');
const { fromCents } = require('../utils/money');
// The 6 calendar months immediately preceding (year, month), newest first, each
// as { y, m, key: 'YYYY-MM' }.
function priorMonths(year, month, count = 6) {
const list = [];
let y = year;
let m = month;
for (let i = 0; i < count; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
list.push({ y, m, key: `${y}-${String(m).padStart(2, '0')}` });
}
return list;
}
// Rolling median of a bill's monthly amounts → a suggestion object (or null).
function medianSuggestion(amounts) {
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
}
/**
* Computes a suggested expected amount for a bill based on the rolling median
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
* (user-corrected values) over raw payment sums.
*/
function computeAmountSuggestion(db, billId, year, month) {
const amounts = [];
for (const { y, m } of priorMonths(year, month, 6)) {
const mbs = db.prepare(
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
).get(billId, y, m);
if (mbs?.actual_amount != null) {
amounts.push(mbs.actual_amount);
continue;
}
const result = db.prepare(`
SELECT COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
AND strftime('%Y', paid_date) = ?
AND strftime('%m', paid_date) = ?
`).get(billId, String(y), String(m).padStart(2, '0'));
if (result.total > 0) amounts.push(result.total);
}
return medianSuggestion(amounts);
}
/**
* Batched form of computeAmountSuggestion for many bills at once: two queries
* total (monthly_bill_state + grouped payment sums) instead of up to 12 per
* bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling
* computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js).
*/
function computeAmountSuggestionsBatch(db, billIds, year, month) {
const result = new Map();
if (!billIds || billIds.length === 0) return result;
const months = priorMonths(year, month, 6);
const monthKeys = new Set(months.map(mm => mm.key));
const minKey = months[months.length - 1].key; // earliest (month 6)
const maxKey = months[0].key; // latest (month 1)
// Broad window that fully spans the 6 months; the monthKeys filter below is
// the exact gate, so a '-01'…'-31' string window is safe for ISO dates.
const windowStart = `${minKey}-01`;
const windowEnd = `${maxKey}-31`;
const placeholders = billIds.map(() => '?').join(',');
// User-corrected monthly amounts.
const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount }
const mbsRows = db.prepare(`
SELECT bill_id, year, month, actual_amount
FROM monthly_bill_state
WHERE bill_id IN (${placeholders})
`).all(...billIds);
for (const r of mbsRows) {
if (r.actual_amount == null) continue;
const key = `${r.year}-${String(r.month).padStart(2, '0')}`;
if (!monthKeys.has(key)) continue;
if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {});
mbsMap.get(r.bill_id)[key] = r.actual_amount;
}
// Payment sums grouped by bill + month.
const payMap = new Map(); // billId → { 'YYYY-MM': total }
const payRows = db.prepare(`
SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE bill_id IN (${placeholders})
AND deleted_at IS NULL
AND ${accountingActiveSql()}
AND paid_date BETWEEN ? AND ?
GROUP BY bill_id, ym
`).all(...billIds, windowStart, windowEnd);
for (const r of payRows) {
if (!monthKeys.has(r.ym)) continue;
if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {});
payMap.get(r.bill_id)[r.ym] = r.total;
}
for (const billId of billIds) {
const amounts = [];
const mbsFor = mbsMap.get(billId) || {};
const payFor = payMap.get(billId) || {};
for (const { key } of months) {
if (mbsFor[key] != null) { amounts.push(mbsFor[key]); continue; }
const total = payFor[key] || 0;
if (total > 0) amounts.push(total);
}
result.set(billId, medianSuggestion(amounts));
}
return result;
}
module.exports = { computeAmountSuggestion, computeAmountSuggestionsBatch };