Files
BalanceMono/BalanceFlutter/lib/domain/finance.dart
T
wt 2c278044bb
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
delete CloudKit and added BalanceFlutter
2026-10-28 15:37:32 +07:00

686 lines
19 KiB
Dart

/// Finance values use hundredths of a currency unit internally. JSON retains
/// the existing iOS / Android / Go synchronization protocol.
enum TransactionKind {
expense,
income;
String get title => this == income ? 'Доход' : 'Расход';
static TransactionKind parse(String value) =>
value == 'income' ? income : expense;
}
int moneyToMinor(num value) {
if (!value.isFinite) throw const FormatException('Invalid money amount');
return (value * 100).round();
}
/// Parse user-entered amounts without passing through binary floating point.
/// An empty value, exponent, sign or more than two decimal places is rejected.
int parseMoney(String value) {
final normalized = value.trim().replaceAll(',', '.');
if (!RegExp(r'^\d+(?:\.\d{1,2})?$').hasMatch(normalized)) {
throw const FormatException(
'Введите положительную сумму, максимум 2 знака после запятой',
);
}
final parts = normalized.split('.');
return int.parse(parts[0]) * 100 +
(parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0')));
}
String formatMinor(int value) {
final absolute = value.abs();
return '${value < 0 ? '-' : ''}${absolute ~/ 100}.${(absolute % 100).toString().padLeft(2, '0')}';
}
DateTime _date(dynamic value) => DateTime.parse(value as String);
String _timestamp(DateTime value) => value.toUtc().toIso8601String();
class TransactionRecord {
final String id;
final int amountMinor;
final DateTime date;
final String note,
categoryName,
categoryIcon,
categoryEmoji,
categoryColorName;
final bool isBalanceAdjustment;
final String kindRawValue;
final DateTime updatedAt;
const TransactionRecord({
required this.id,
required this.amountMinor,
required this.date,
required this.categoryName,
required this.updatedAt,
this.note = '',
this.categoryIcon = 'circle.fill',
this.categoryEmoji = '',
this.categoryColorName = 'auto',
this.isBalanceAdjustment = false,
this.kindRawValue = 'expense',
});
double get amount => amountMinor / 100;
TransactionKind get kind => TransactionKind.parse(kindRawValue);
Map<String, dynamic> payload() => {
'amount': amount,
'date': _timestamp(date),
'note': note,
'categoryName': categoryName,
'categoryIcon': categoryIcon,
'categoryEmoji': categoryEmoji,
'categoryColorName': categoryColorName,
'isBalanceAdjustment': isBalanceAdjustment,
'kindRawValue': kindRawValue,
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
'amountMinor': amountMinor,
};
factory TransactionRecord.fromJson(Map<String, dynamic> json) =>
TransactionRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
).copyWith(amountMinor: json['amountMinor'] as int?);
factory TransactionRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => TransactionRecord(
id: id,
updatedAt: updatedAt,
amountMinor: moneyToMinor(payload['amount'] as num),
date: _date(payload['date']),
categoryName: payload['categoryName'] as String,
note: payload['note'] as String? ?? '',
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
categoryColorName: payload['categoryColorName'] as String? ?? 'auto',
isBalanceAdjustment: payload['isBalanceAdjustment'] as bool? ?? false,
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
);
TransactionRecord copyWith({
String? id,
int? amountMinor,
DateTime? date,
String? note,
String? categoryName,
String? categoryIcon,
String? categoryEmoji,
String? categoryColorName,
bool? isBalanceAdjustment,
String? kindRawValue,
DateTime? updatedAt,
}) => TransactionRecord(
id: id ?? this.id,
amountMinor: amountMinor ?? this.amountMinor,
date: date ?? this.date,
updatedAt: updatedAt ?? this.updatedAt,
note: note ?? this.note,
categoryName: categoryName ?? this.categoryName,
categoryIcon: categoryIcon ?? this.categoryIcon,
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
categoryColorName: categoryColorName ?? this.categoryColorName,
isBalanceAdjustment: isBalanceAdjustment ?? this.isBalanceAdjustment,
kindRawValue: kindRawValue ?? this.kindRawValue,
);
}
class CategoryRecord {
final String id, name, icon, emoji, colorName, kindRawValue;
final DateTime createdAt, updatedAt;
const CategoryRecord({
required this.id,
required this.name,
required this.icon,
required this.colorName,
required this.createdAt,
required this.updatedAt,
this.emoji = '',
this.kindRawValue = 'expense',
});
TransactionKind get kind => TransactionKind.parse(kindRawValue);
Map<String, dynamic> payload() => {
'name': name,
'icon': icon,
'emoji': emoji,
'colorName': colorName,
'kindRawValue': kindRawValue,
'createdAt': _timestamp(createdAt),
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
};
factory CategoryRecord.fromJson(Map<String, dynamic> json) =>
CategoryRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
);
factory CategoryRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => CategoryRecord(
id: id,
updatedAt: updatedAt,
name: payload['name'] as String,
icon: payload['icon'] as String? ?? 'star.fill',
emoji: payload['emoji'] as String? ?? '',
colorName: payload['colorName'] as String? ?? 'indigo',
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
createdAt: payload['createdAt'] == null
? updatedAt
: _date(payload['createdAt']),
);
CategoryRecord copyWith({
String? id,
String? name,
String? icon,
String? emoji,
String? colorName,
String? kindRawValue,
DateTime? createdAt,
DateTime? updatedAt,
}) => CategoryRecord(
id: id ?? this.id,
name: name ?? this.name,
icon: icon ?? this.icon,
emoji: emoji ?? this.emoji,
colorName: colorName ?? this.colorName,
kindRawValue: kindRawValue ?? this.kindRawValue,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
class BudgetRecord {
final String id, categoryName, categoryIcon, categoryEmoji;
final int limitMinor;
final DateTime monthStart, updatedAt;
const BudgetRecord({
required this.id,
required this.categoryName,
required this.limitMinor,
required this.monthStart,
required this.updatedAt,
this.categoryIcon = 'circle.fill',
this.categoryEmoji = '',
});
double get limit => limitMinor / 100;
Map<String, dynamic> payload() => {
'categoryName': categoryName,
'categoryIcon': categoryIcon,
'categoryEmoji': categoryEmoji,
'limit': limit,
'monthStart': _timestamp(monthStart),
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
'limitMinor': limitMinor,
};
factory BudgetRecord.fromJson(Map<String, dynamic> json) =>
BudgetRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
).copyWith(limitMinor: json['limitMinor'] as int?);
factory BudgetRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => BudgetRecord(
id: id,
updatedAt: updatedAt,
categoryName: payload['categoryName'] as String,
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
limitMinor: moneyToMinor(payload['limit'] as num),
monthStart: _date(payload['monthStart']),
);
BudgetRecord copyWith({
String? id,
String? categoryName,
String? categoryIcon,
String? categoryEmoji,
int? limitMinor,
DateTime? monthStart,
DateTime? updatedAt,
}) => BudgetRecord(
id: id ?? this.id,
categoryName: categoryName ?? this.categoryName,
categoryIcon: categoryIcon ?? this.categoryIcon,
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
limitMinor: limitMinor ?? this.limitMinor,
monthStart: monthStart ?? this.monthStart,
updatedAt: updatedAt ?? this.updatedAt,
);
}
class Tombstone {
final String entity, id;
final DateTime updatedAt;
const Tombstone({
required this.entity,
required this.id,
required this.updatedAt,
});
String get recordID => id;
Map<String, dynamic> toJson() => {
'entity': entity,
'id': id,
'deleted': true,
'updatedAt': _timestamp(updatedAt),
};
factory Tombstone.fromJson(Map<String, dynamic> json) => Tombstone(
entity: json['entity'] as String,
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
);
}
class FinanceSummary {
final int incomeMinor, expensesMinor;
const FinanceSummary({
required this.incomeMinor,
required this.expensesMinor,
});
int get balanceMinor => incomeMinor - expensesMinor;
double get income => incomeMinor / 100;
double get expenses => expensesMinor / 100;
double get balance => balanceMinor / 100;
}
class CategorySpending {
final String name, icon, emoji, colorName;
final int amountMinor;
const CategorySpending({
required this.name,
required this.icon,
required this.emoji,
required this.colorName,
required this.amountMinor,
});
double get amount => amountMinor / 100;
}
class MonthlyTotal {
final DateTime month;
final FinanceSummary summary;
const MonthlyTotal(this.month, this.summary);
}
class FinanceMath {
static int totalBalance(Iterable<TransactionRecord> items) => items.fold(
0,
(value, item) =>
value +
(item.kind == TransactionKind.income
? item.amountMinor
: -item.amountMinor),
);
static DateTime monthStart(DateTime date) => date.isUtc
? DateTime.utc(date.year, date.month)
: DateTime(date.year, date.month);
static DateTime nextMonth(DateTime date) => date.isUtc
? DateTime.utc(date.year, date.month + 1)
: DateTime(date.year, date.month + 1);
static bool _inPeriod(TransactionRecord item, DateTime start, DateTime end) =>
!item.isBalanceAdjustment &&
!item.date.isBefore(start) &&
item.date.isBefore(end);
static FinanceSummary summary(
Iterable<TransactionRecord> items,
DateTime start,
DateTime endExclusive,
) {
var income = 0;
var expenses = 0;
for (final item in items) {
if (!_inPeriod(item, start, endExclusive)) continue;
if (item.kind == TransactionKind.income) {
income += item.amountMinor;
} else {
expenses += item.amountMinor;
}
}
return FinanceSummary(incomeMinor: income, expensesMinor: expenses);
}
static List<CategorySpending> spending(
Iterable<TransactionRecord> items,
DateTime start,
DateTime endExclusive,
) {
final grouped = <String, CategorySpending>{};
for (final item in items) {
if (item.kind != TransactionKind.expense ||
!_inPeriod(item, start, endExclusive)) {
continue;
}
final existing = grouped[item.categoryName];
grouped[item.categoryName] = CategorySpending(
name: item.categoryName,
icon: existing?.icon ?? item.categoryIcon,
emoji: existing?.emoji ?? item.categoryEmoji,
colorName: existing?.colorName ?? item.categoryColorName,
amountMinor: (existing?.amountMinor ?? 0) + item.amountMinor,
);
}
return grouped.values.toList()..sort((a, b) {
final byAmount = b.amountMinor.compareTo(a.amountMinor);
return byAmount == 0 ? a.name.compareTo(b.name) : byAmount;
});
}
/// Includes empty months, ordered from oldest to newest.
static List<MonthlyTotal> monthlyTrend(
Iterable<TransactionRecord> items,
DateTime through, {
int months = 6,
}) {
if (months < 0) throw ArgumentError.value(months, 'months');
final snapshot = items.toList();
return List.generate(months, (index) {
final month = through.isUtc
? DateTime.utc(through.year, through.month - months + 1 + index)
: DateTime(through.year, through.month - months + 1 + index);
return MonthlyTotal(month, summary(snapshot, month, nextMonth(month)));
});
}
static int budgetUsed(
BudgetRecord budget,
Iterable<TransactionRecord> items,
) {
// Native clients serialize local month starts as UTC instants. In positive
// offsets January 1 becomes December 31 on the wire; recover the local
// calendar month before building its spending interval.
final start = monthStart(budget.monthStart.toLocal());
return items
.where(
(item) =>
item.categoryName == budget.categoryName &&
item.kind == TransactionKind.expense &&
_inPeriod(item, start, nextMonth(start)),
)
.fold(0, (value, item) => value + item.amountMinor);
}
static String escapeCsv(String value) => '"${value.replaceAll('"', '""')}"';
static String exportCsv(Iterable<TransactionRecord> items) {
final ordered = items.toList()..sort((a, b) => b.date.compareTo(a.date));
return [
'Дата,Тип,Категория,Сумма,Заметка',
...ordered.map(
(item) => [
_timestamp(item.date),
item.isBalanceAdjustment ? 'Корректировка' : item.kind.title,
item.categoryName,
formatMinor(item.amountMinor),
item.note,
].map(escapeCsv).join(','),
),
].join('\n');
}
}
class CategoryDesignOptions {
static const icons = <String>[
'star.fill',
'heart.fill',
'sparkles',
'bolt.fill',
'flame.fill',
'drop.fill',
'cart.fill',
'bag.fill',
'creditcard.fill',
'banknote.fill',
'gift.fill',
'tag.fill',
'cup.and.saucer.fill',
'fork.knife',
'takeoutbag.and.cup.and.straw.fill',
'birthday.cake.fill',
'car.fill',
'bus.fill',
'tram.fill',
'bicycle',
'airplane',
'fuelpump.fill',
'house.fill',
'bed.double.fill',
'lightbulb.fill',
'wifi',
'phone.fill',
'printer.fill',
'pawprint.fill',
'leaf.fill',
'figure.walk',
'figure.run',
'dumbbell.fill',
'cross.case.fill',
'pills.fill',
'stethoscope',
'book.fill',
'graduationcap.fill',
'laptopcomputer',
'briefcase.fill',
'gamecontroller.fill',
'music.note',
'film.fill',
'camera.fill',
'paintpalette.fill',
'tshirt.fill',
'hammer.fill',
'wrench.and.screwdriver.fill',
'shippingbox.fill',
'globe.europe.africa.fill',
];
static const emojis = <String>[
'🍔',
'☕️',
'🛒',
'🚕',
'✈️',
'🏠',
'💡',
'📱',
'💻',
'🎮',
'🎬',
'🎵',
'📚',
'🎓',
'💊',
'🏋️',
'🐶',
'🐱',
'👕',
'🎁',
'💰',
'💳',
'📈',
'🔧',
'🌿',
'❤️',
'⭐️',
'🎯',
'🧾',
'🏖️',
];
static const colors = <String>[
'red',
'orange',
'yellow',
'green',
'mint',
'teal',
'cyan',
'blue',
'indigo',
'purple',
'pink',
'gray',
'coral',
'peach',
'gold',
'lime',
'forest',
'turquoise',
'sky',
'navy',
'lavender',
'magenta',
'brown',
'slate',
];
}
class BuiltInCategories {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
static final expense = List<CategoryRecord>.unmodifiable([
CategoryRecord(
id: 'food',
name: 'Продукты',
icon: 'cart.fill',
colorName: 'orange',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'transport',
name: 'Транспорт',
icon: 'car.fill',
colorName: 'blue',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'home',
name: 'Дом',
icon: 'house.fill',
colorName: 'indigo',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'entertainment',
name: 'Развлечения',
icon: 'gamecontroller.fill',
colorName: 'pink',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'health',
name: 'Здоровье',
icon: 'cross.case.fill',
colorName: 'red',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'shopping',
name: 'Покупки',
icon: 'bag.fill',
colorName: 'purple',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'subscriptions',
name: 'Подписки',
icon: 'repeat',
colorName: 'cyan',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'education',
name: 'Образование',
icon: 'book.fill',
colorName: 'mint',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'other-expense',
name: 'Другое',
icon: 'ellipsis.circle.fill',
colorName: 'gray',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
]);
static final income = List<CategoryRecord>.unmodifiable([
CategoryRecord(
id: 'salary',
name: 'Зарплата',
icon: 'banknote.fill',
colorName: 'green',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'freelance',
name: 'Подработка',
icon: 'laptopcomputer',
colorName: 'teal',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'investments',
name: 'Инвестиции',
icon: 'chart.line.uptrend.xyaxis',
colorName: 'blue',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'gift',
name: 'Подарок',
icon: 'gift.fill',
colorName: 'pink',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'other-income',
name: 'Другое',
icon: 'plus.circle.fill',
colorName: 'gray',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
]);
static List<CategoryRecord> forKind(String kind) =>
kind == 'income' ? income : expense;
}