272 lines
8.0 KiB
Dart
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,
|
|
);
|
|
}
|