/// Project future balances from recurring templates. 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.templateId, required this.description, required this.amount, required this.currency, required this.dayOfMonth, this.ruleSummary = '', this.sourceTxId, this.labelIds = const [], }); final String templateId; final String description; final double amount; final String currency; final int dayOfMonth; final String ruleSummary; final String? sourceTxId; final List labelIds; } /// Build projection items from first-class [templates]. List recurringItemsFromTemplates({ required Iterable templates, required List transactions, required bool Function(BankTransaction tx) isInternalTransfer, required List Function(BankTransaction tx) labelIdsFor, String Function(String? labelId)? labelName, }) { return [ for (final tpl in templates) RecurringItem( templateId: tpl.id, description: tpl.name, amount: resolveRecurringAmount( template: tpl, transactions: transactions, isInternalTransfer: isInternalTransfer, labelIdsFor: labelIdsFor, ), currency: tpl.currency, dayOfMonth: tpl.dayOfMonth.clamp(1, 28), ruleSummary: recurringRuleSummary( template: tpl, labelName: labelName, ), sourceTxId: tpl.sourceTxId, labelIds: [ if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!, ], ), ]; } String recurringRuleSummary({ required RecurringTemplate template, String Function(String? labelId)? labelName, }) { switch (template.amountRule) { case RecurringAmountRule.lastValue: return 'Last value'; case RecurringAmountRule.avgLastTxs: return 'Avg last ${template.avgCount} matching'; case RecurringAmountRule.labelAvgMonths: final name = labelName?.call(template.labelId) ?? 'label'; return 'Avg ${template.avgCount} mo ยท $name'; } } /// Resolve projected amount for [template] from bank history. double resolveRecurringAmount({ required RecurringTemplate template, required List transactions, required bool Function(BankTransaction tx) isInternalTransfer, required List Function(BankTransaction tx) labelIdsFor, }) { switch (template.amountRule) { case RecurringAmountRule.lastValue: return _lastMerchantAmount( template: template, transactions: transactions, ) ?? 0; case RecurringAmountRule.avgLastTxs: return avgLastMatchingTxs( count: template.avgCount, transactions: transactions, merchantKey: template.merchantKey, sourceTxId: template.sourceTxId, ); case RecurringAmountRule.labelAvgMonths: final labelId = template.labelId; if (labelId == null || labelId.isEmpty) return 0; return avgLabelMonths( labelId: labelId, months: template.avgCount, transactions: transactions, isInternalTransfer: isInternalTransfer, labelIdsFor: labelIdsFor, ); } } double? _lastMerchantAmount({ required RecurringTemplate template, required List transactions, }) { if (template.sourceTxId != null) { for (final tx in transactions) { if (tx.id == template.sourceTxId) return tx.amount; } } final key = template.merchantKey; if (key.isEmpty) return null; BankTransaction? latest; DateTime? latestDate; for (final tx in transactions) { if (tx.merchantKey != key) continue; final date = DateTime.tryParse(tx.date); if (date == null) continue; if (latest == null || date.isAfter(latestDate!)) { latest = tx; latestDate = date; } } return latest?.amount; } /// Average of the last [count] txs that match the selected source /// (same similar-pattern + flow as apply-to-similar). double avgLastMatchingTxs({ required int count, required List transactions, String merchantKey = '', String? sourceTxId, }) { final n = count.clamp(1, 36); BankTransaction? prototype; if (sourceTxId != null && sourceTxId.isNotEmpty) { for (final tx in transactions) { if (tx.id == sourceTxId) { prototype = tx; break; } } } if (prototype == null && merchantKey.isNotEmpty) { DateTime? latestDate; for (final tx in transactions) { if (tx.merchantKey != merchantKey) continue; final date = DateTime.tryParse(tx.date); if (date == null) continue; if (prototype == null || date.isAfter(latestDate!)) { prototype = tx; latestDate = date; } } } if (prototype == null) return 0; final criteria = SimilarMatchCriteria( pattern: defaultSimilarPattern(prototype.description), flow: prototype.amount < 0 ? TxFlowFilter.expense : prototype.amount > 0 ? TxFlowFilter.income : TxFlowFilter.all, ); final matches = <({BankTransaction tx, DateTime date})>[]; for (final tx in transactions) { if (!txMatchesSimilarCriteria(tx, criteria)) continue; final date = DateTime.tryParse(tx.date); if (date == null) continue; matches.add((tx: tx, date: date)); } if (matches.isEmpty) return prototype.amount; matches.sort((a, b) => b.date.compareTo(a.date)); final take = matches.take(n).toList(growable: false); final sum = take.fold(0, (s, e) => s + e.tx.amount); return sum / take.length; } /// Average monthly signed total for [labelId] over the last [months] completed months. double avgLabelMonths({ required String labelId, required int months, required List transactions, required bool Function(BankTransaction tx) isInternalTransfer, required List Function(BankTransaction tx) labelIdsFor, DateTime? now, }) { final n = months.clamp(1, 36); final today = dateOnly(now ?? DateTime.now()); final currentMonth = monthStart(today); final totals = {}; for (var i = 1; i <= n; i++) { totals[addMonths(currentMonth, -i)] = 0; } for (final tx in transactions) { if (isInternalTransfer(tx)) continue; final date = DateTime.tryParse(tx.date); if (date == null) continue; final key = monthStart(date); if (!totals.containsKey(key)) continue; final ids = labelIdsFor(tx); if (!ids.contains(labelId)) continue; totals[key] = (totals[key] ?? 0) + tx.amount; } final sum = totals.values.fold(0, (s, v) => s + v); return sum / n; } /// 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, ); }