ok for now
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/// 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<RecurringItem> recurringItemsFrom({
|
||||
required List<BankTransaction> transactions,
|
||||
required TxMeta Function(BankTransaction tx) metaFor,
|
||||
}) {
|
||||
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)
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user