BillTracker/services/amountSuggestionService.js

138 lines
4.7 KiB
JavaScript
Raw Normal View History

2026-05-28 02:09:49 -05:00
'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',
};
}
2026-05-28 02:09:49 -05:00
/**
* 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)) {
2026-05-28 02:09:49 -05:00
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()}
2026-05-28 02:09:49 -05:00
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);
}
2026-05-28 02:09:49 -05:00
/**
* 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;
2026-05-28 02:09:49 -05:00
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;
2026-05-28 02:09:49 -05:00
}
module.exports = { computeAmountSuggestion, computeAmountSuggestionsBatch };