Files
ingtrakapp/lib/utils/look_ahead.dart
T
2026-09-06 21:58:59 +02:00

106 lines
2.9 KiB
Dart

/// Project future balances from recurring transactions.
library;
import '../models/bank.dart';
import '../models/transaction_meta.dart';
import 'tx_calendar.dart';
/// One monthly recurring item used for look-ahead forecasts.
class RecurringItem {
const RecurringItem({
required this.description,
required this.amount,
required this.currency,
required this.dayOfMonth,
required this.category,
});
final String description;
final double amount;
final String currency;
final int dayOfMonth;
final TxCategory category;
}
/// Build unique recurring templates from annotated transactions.
List<RecurringItem> recurringItemsFrom({
required List<BankTransaction> transactions,
required TxMeta Function(BankTransaction tx) metaFor,
}) {
final latestByExact = <String, BankTransaction>{};
for (final tx in transactions) {
final meta = metaFor(tx);
if (!meta.recurring) continue;
final existing = latestByExact[tx.exactKey];
if (existing == null) {
latestByExact[tx.exactKey] = tx;
continue;
}
final existingDate = DateTime.tryParse(existing.date);
final nextDate = DateTime.tryParse(tx.date);
if (existingDate == null ||
(nextDate != null && nextDate.isAfter(existingDate))) {
latestByExact[tx.exactKey] = tx;
}
}
return [
for (final tx in latestByExact.values)
RecurringItem(
description: tx.description,
amount: tx.amount,
currency: tx.currency,
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
category: metaFor(tx).category,
),
];
}
/// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end).
List<({RecurringItem item, DateTime date})> projectedOccurrences({
required List<RecurringItem> items,
required DateTime from,
required DateTime to,
}) {
final start = dateOnly(from);
final end = dateOnly(to);
if (!end.isAfter(start)) return const [];
final out = <({RecurringItem item, DateTime date})>[];
var cursor = monthStart(start);
final lastMonth = monthStart(end);
while (!cursor.isAfter(lastMonth)) {
final daysInMonth = DateTime(cursor.year, cursor.month + 1, 0).day;
for (final item in items) {
final day = item.dayOfMonth.clamp(1, daysInMonth);
final date = DateTime(cursor.year, cursor.month, day);
if (date.isAfter(start) && !date.isAfter(end)) {
out.add((item: item, date: date));
}
}
cursor = addMonths(cursor, 1);
}
out.sort((a, b) => a.date.compareTo(b.date));
return out;
}
/// Current balance plus projected recurring amounts through [throughDate].
double predictBalance({
required double currentBalance,
required DateTime from,
required DateTime throughDate,
required List<RecurringItem> items,
}) {
final projected = projectedOccurrences(
items: items,
from: from,
to: throughDate,
);
return projected.fold<double>(
currentBalance,
(sum, row) => sum + row.item.amount,
);
}