'use strict'; const { getDb } = require('../db/database'); const { getCycleRange } = require('./statusService'); const { getUserSettings } = require('./userSettings'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; const MIN_ABS_DELTA = 1.00; function median(arr) { if (!arr.length) return 0; const sorted = [...arr].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function monthEnd(year, month) { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } function getDriftReport(userId, now = new Date()) { try { const db = getDb(); const settings = getUserSettings(userId); const thresholdPct = Math.max(1, Math.min(25, parseFloat(settings.drift_threshold_pct ?? '5') || 5 )); const bills = db.prepare(` SELECT b.*, c.name AS category_name FROM bills b LEFT JOIN categories c ON c.id = b.category_id WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL `).all(userId); const todayStr = now.toISOString().slice(0, 10); const drifted = []; const mbsStmt = db.prepare( 'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?' ); const payStmt = db.prepare(` SELECT COALESCE(SUM(amount), 0) AS total FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL `); for (const bill of bills) { if (!bill.expected_amount || bill.expected_amount <= 0) continue; if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue; const monthTotals = []; for (let i = 1; i <= MONTHS_BACK; i++) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); const yr = d.getUTCFullYear(); const mo = d.getUTCMonth() + 1; // Skip if bill was created after this month ended const monthEndStr = `${yr}-${String(mo).padStart(2,'0')}-${String(monthEnd(yr, mo)).padStart(2,'0')}`; if (bill.created_at && bill.created_at.slice(0, 10) > monthEndStr) continue; const mbs = mbsStmt.get(bill.id, yr, mo); if (mbs?.is_skipped) continue; const range = getCycleRange(yr, mo, bill); if (!range) continue; const { total } = payStmt.get(bill.id, range.start, range.end); if (total > 0) monthTotals.push(total); } if (monthTotals.length < MIN_PAID_MONTHS) continue; const recentAmount = median(monthTotals); const delta = recentAmount - bill.expected_amount; const absDelta = Math.abs(delta); const driftPct = (delta / bill.expected_amount) * 100; if (absDelta < MIN_ABS_DELTA) continue; if (Math.abs(driftPct) < thresholdPct) continue; drifted.push({ id: bill.id, name: bill.name, category_name: bill.category_name ?? null, expected_amount: bill.expected_amount, recent_amount: Math.round(recentAmount * 100) / 100, drift_pct: Math.round(driftPct * 10) / 10, direction: delta > 0 ? 'up' : 'down', months_sampled: monthTotals.length, drift_snoozed_until: bill.drift_snoozed_until ?? null, }); } return { bills: drifted, threshold_pct: thresholdPct }; } catch (err) { console.error('[driftService] getDriftReport error:', err.message); return { bills: [], threshold_pct: 5, error: err.message }; } } module.exports = { getDriftReport };