Files
2026-09-10 00:35:54 +02:00

272 lines
8.0 KiB
Dart

/// 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<String> labelIds;
}
/// Build projection items from first-class [templates].
List<RecurringItem> recurringItemsFromTemplates({
required Iterable<RecurringTemplate> templates,
required List<BankTransaction> transactions,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> 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<BankTransaction> transactions,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> 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<BankTransaction> 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<BankTransaction> 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<double>(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<BankTransaction> transactions,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> Function(BankTransaction tx) labelIdsFor,
DateTime? now,
}) {
final n = months.clamp(1, 36);
final today = dateOnly(now ?? DateTime.now());
final currentMonth = monthStart(today);
final totals = <DateTime, double>{};
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<double>(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<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,
);
}