look ahead
This commit is contained in:
+193
-27
@@ -1,4 +1,4 @@
|
||||
/// Project future balances from recurring transactions.
|
||||
/// Project future balances from recurring templates.
|
||||
library;
|
||||
|
||||
import '../models/bank.dart';
|
||||
@@ -8,54 +8,220 @@ 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 unique recurring templates from annotated transactions.
|
||||
List<RecurringItem> recurringItemsFrom({
|
||||
/// Build projection items from first-class [templates].
|
||||
List<RecurringItem> recurringItemsFromTemplates({
|
||||
required Iterable<RecurringTemplate> templates,
|
||||
required List<BankTransaction> transactions,
|
||||
required TxMeta Function(BankTransaction tx) metaFor,
|
||||
required bool Function(BankTransaction tx) isInternalTransfer,
|
||||
required List<String> Function(BankTransaction tx) labelIdsFor,
|
||||
String Function(String? labelId)? labelName,
|
||||
}) {
|
||||
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)
|
||||
for (final tpl in templates)
|
||||
RecurringItem(
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||
labelIds: List<String>.of(metaFor(tx).labelIds),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user