'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. 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 };