/// 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 recurringItemsFrom({ required List transactions, required TxMeta Function(BankTransaction tx) metaFor, }) { final latestByExact = {}; 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 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 items, }) { final projected = projectedOccurrences( items: items, from: from, to: throughDate, ); return projected.fold( currentBalance, (sum, row) => sum + row.item.amount, ); }