BillTracker/services/driftService.cts

136 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

'use strict';
import type { Dollars } from '../utils/money.mts';
const { getDb } = require('../db/database.cts');
const { log } = require('../utils/logger.cts');
const { getCycleRange } = require('./statusService.cts');
const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { getUserSettings } = require('./userSettings.cts');
const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { roundMoney, fromCents } =
require('../utils/money.mts') as typeof import('../utils/money.mts');
const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2;
const MIN_ABS_DELTA = 1.0;
interface DriftBillRow {
id: any;
name: any;
category_name: any;
expected_amount: Dollars;
recent_amount: Dollars;
drift_pct: number;
direction: 'up' | 'down';
months_sampled: number;
drift_snoozed_until: any;
}
interface DriftReport {
bills: DriftBillRow[];
threshold_pct: number;
error?: string;
}
function median(arr: number[]): number {
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: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
function getDriftReport(userId: number, now: Date = new Date()): DriftReport {
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 = localDateString(now);
const drifted: DriftBillRow[] = [];
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
AND ${accountingActiveSql()}
`);
for (const bill of bills) {
const expectedAmount = fromCents(bill.expected_amount);
if (!expectedAmount || expectedAmount <= 0) continue;
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
const monthTotals: number[] = [];
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(fromCents(total)!); // total>0 ⇒ fromCents non-null
}
if (monthTotals.length < MIN_PAID_MONTHS) continue;
const recentAmount = median(monthTotals);
const delta = recentAmount - expectedAmount;
const absDelta = Math.abs(delta);
const driftPct = (delta / expectedAmount) * 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: expectedAmount,
recent_amount: roundMoney(recentAmount),
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: any) {
log.error('[driftService] getDriftReport error:', err.message);
return { bills: [], threshold_pct: 5, error: err.message };
}
}
module.exports = { getDriftReport };