106 lines
2.9 KiB
Dart
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,
|
|
);
|
|
}
|