feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(double width) {
|
||||
Widget wrap(double width, {String location = '/'}) {
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 800)),
|
||||
child: MaterialApp(
|
||||
home: AppShell(location: '/', child: Container()),
|
||||
home: AppShell(location: location, child: Container()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -23,4 +23,22 @@ void main() {
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the resolve screen keeps Импорт selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/instruments/pending'));
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(
|
||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||
'Импорт',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a nested route keeps its own section selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311'));
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(
|
||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||
'Портфель',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:fintracker_app/features/imports/data/imports_api.dart';
|
||||
import 'package:fintracker_app/features/pending/data/pending_api.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The exact payload from `docs/ai/import-contract.md`. If the backend renames a field this
|
||||
/// test is the first thing that notices.
|
||||
const _previewJson = <String, dynamic>{
|
||||
'id': 12,
|
||||
'broker': 'sber',
|
||||
'filename': 'S930W42_11022026_17092026.html',
|
||||
'sha256': '9f2c',
|
||||
'size_bytes': 87333,
|
||||
'parser_name': 'report_sber',
|
||||
'parser_version': '1',
|
||||
'parse_status': 'parsed',
|
||||
'error': null,
|
||||
'duplicate_of_id': null,
|
||||
'account_external_id': 'S930W42',
|
||||
'account_id': 57,
|
||||
'account_name': 'Сбер ИИС',
|
||||
'account_suggestions': [
|
||||
{'id': 57, 'name': 'Сбер ИИС', 'broker': 'sber', 'source_id': 'S930W42'},
|
||||
],
|
||||
'period_from': '2026-02-11',
|
||||
'period_to': '2026-09-17',
|
||||
'uploaded_at': '2026-09-18T12:30:00+03:00',
|
||||
'committed_at': null,
|
||||
'counts': {
|
||||
'lines': 61,
|
||||
'events_total': 47,
|
||||
'events_new': 47,
|
||||
'events_duplicate': 0,
|
||||
'events_shadow': 0,
|
||||
'events_pending': 3,
|
||||
'by_kind': {'buy': 22, 'sell': 2, 'deposit': 8, 'commission': 15},
|
||||
},
|
||||
'pending_instruments': [
|
||||
{
|
||||
'id': 4,
|
||||
'source': 'report_sber',
|
||||
'source_key': 'ISIN:RU000A1035S8',
|
||||
'isin': 'RU000A1035S8',
|
||||
'ticker': 'STME',
|
||||
'board': null,
|
||||
'name': 'Первая-ВечныйПортф БПИФ',
|
||||
'currency': 'RUB',
|
||||
'asset_class_hint': 'fund',
|
||||
'occurrences': 3,
|
||||
'sample_quantity': '439',
|
||||
'sample_price': '4.48',
|
||||
'status': 'pending',
|
||||
'instrument_id': null,
|
||||
},
|
||||
],
|
||||
'reconciliation': {
|
||||
'as_of': '2026-09-17',
|
||||
'positions': [
|
||||
{
|
||||
'instrument_id': 88,
|
||||
'instrument_name': 'Аэрофлот',
|
||||
'ticker': 'AFLT',
|
||||
'isin': 'RU0009062285',
|
||||
'qty_report': '130',
|
||||
'qty_derived': '130',
|
||||
'qty_delta': '0',
|
||||
'matches': true,
|
||||
},
|
||||
],
|
||||
'cash': [
|
||||
{
|
||||
'currency': 'RUB',
|
||||
'balance_report': '3171.34',
|
||||
'balance_derived': '3171.34',
|
||||
'delta': '0',
|
||||
'matches': true,
|
||||
},
|
||||
],
|
||||
'matches': true,
|
||||
},
|
||||
'warnings': ['Раздел «Купонный доход» отсутствует в файле'],
|
||||
'sample_events': [
|
||||
{
|
||||
'line_no': 3,
|
||||
'kind': 'buy',
|
||||
'trade_date': '2026-02-24',
|
||||
'settle_date': '2026-02-25',
|
||||
'instrument_key': 'ISIN:RU000A1035S8',
|
||||
'instrument_name': 'Первая-ВечныйПортф БПИФ',
|
||||
'instrument_id': null,
|
||||
'quantity': '439',
|
||||
'price': '4.48',
|
||||
'amount': '-1966.72',
|
||||
'currency': 'RUB',
|
||||
'fee': '0.39',
|
||||
'trade_no': '15678045077',
|
||||
'dedupe_key': 'a1b2',
|
||||
'is_duplicate': false,
|
||||
'description': 'Покупка',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
test('parses the contract ImportPreview payload', () {
|
||||
final p = ImportPreview.fromJson(_previewJson);
|
||||
|
||||
expect(p.id, 12);
|
||||
expect(p.broker, 'sber');
|
||||
expect(p.parseStatus, 'parsed');
|
||||
expect(p.accountId, 57);
|
||||
expect(p.periodFrom, DateTime.parse('2026-02-11'));
|
||||
expect(p.counts.eventsPending, 3);
|
||||
expect(p.counts.byKind['buy'], 22);
|
||||
expect(p.accountSuggestions.single.sourceId, 'S930W42');
|
||||
expect(p.warnings, hasLength(1));
|
||||
expect(p.canCommit, isTrue);
|
||||
|
||||
// Money and quantities stay strings end to end — never parsed into a double here.
|
||||
final sample = p.sampleEvents.single;
|
||||
expect(sample.amount, '-1966.72');
|
||||
expect(sample.price, '4.48');
|
||||
expect(sample.isDuplicate, isFalse);
|
||||
|
||||
final recon = p.reconciliation!;
|
||||
expect(recon.matches, isTrue);
|
||||
expect(recon.positions.single.qtyDelta, '0');
|
||||
expect(recon.cash.single.balanceReport, '3171.34');
|
||||
|
||||
expect(p.pendingInstruments.single.ticker, 'STME');
|
||||
});
|
||||
|
||||
test('an ImportSummary without the preview-only sections still parses', () {
|
||||
final json = Map<String, dynamic>.from(_previewJson)
|
||||
..remove('sample_events')
|
||||
..remove('pending_instruments')
|
||||
..remove('reconciliation');
|
||||
final p = ImportPreview.fromJson(json);
|
||||
|
||||
expect(p.sampleEvents, isEmpty);
|
||||
expect(p.pendingInstruments, isEmpty);
|
||||
expect(p.reconciliation, isNull);
|
||||
});
|
||||
|
||||
test('commit is blocked until an account is known', () {
|
||||
final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null;
|
||||
expect(ImportPreview.fromJson(json).canCommit, isFalse);
|
||||
|
||||
final failed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'failed';
|
||||
expect(ImportPreview.fromJson(failed).canCommit, isFalse);
|
||||
expect(ImportPreview.fromJson(failed).canDelete, isTrue);
|
||||
|
||||
final committed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'committed';
|
||||
expect(ImportPreview.fromJson(committed).canDelete, isFalse);
|
||||
});
|
||||
|
||||
test('parses PendingResolveResult and builds the create body as plain strings', () {
|
||||
final r = PendingResolveResult.fromJson(const {
|
||||
'id': 4,
|
||||
'status': 'resolved',
|
||||
'instrument_id': 88,
|
||||
'events_bound': 3,
|
||||
'alias_created': true,
|
||||
'metrics_refreshed': true,
|
||||
});
|
||||
expect(r.eventsBound, 3);
|
||||
expect(r.instrumentId, 88);
|
||||
|
||||
final body = const NewInstrument(
|
||||
assetClass: 'index',
|
||||
name: 'Индекс МосБиржи',
|
||||
currency: 'RUB',
|
||||
isin: '',
|
||||
ticker: 'IMOEX',
|
||||
lot: 1,
|
||||
).toJson();
|
||||
expect(body['asset_class'], 'index');
|
||||
expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted');
|
||||
expect(body['ticker'], 'IMOEX');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_app/features/goals/data/goals_api.dart';
|
||||
import 'package:fintracker_app/features/income/data/income_api.dart';
|
||||
import 'package:fintracker_app/features/portfolio/data/benchmarks_api.dart';
|
||||
import 'package:fintracker_app/features/rebalance/data/rebalance_api.dart';
|
||||
import 'package:fintracker_app/features/rebalance/weights.dart';
|
||||
import 'package:fintracker_app/features/tax/data/tax_api.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The exact payloads from `docs/ai/phase4-contract.md`. If the backend renames a field or
|
||||
/// changes a shape, these tests are the first thing that notices — the hand-written layer
|
||||
/// has no generated code to fail the build for it.
|
||||
void main() {
|
||||
group('income', () {
|
||||
const calendarJson = <String, dynamic>{
|
||||
'as_of': '2026-09-18',
|
||||
'currency': 'RUB',
|
||||
'total_expected_rub': '14230.50',
|
||||
'entries': [
|
||||
{
|
||||
'instrument_id': 88,
|
||||
'ticker': 'SBER',
|
||||
'name': 'Сбербанк России',
|
||||
'kind': 'dividend',
|
||||
'expected_date': '2026-10-12',
|
||||
'record_date': '2026-10-09',
|
||||
'qty': '20',
|
||||
'per_unit': '34.84',
|
||||
'amount': '696.80',
|
||||
'currency': 'RUB',
|
||||
'amount_rub': '696.80',
|
||||
'basis': 'announced',
|
||||
'tax_withheld': null,
|
||||
},
|
||||
],
|
||||
'by_basis': {'schedule': '8100.00', 'announced': '4130.50', 'history': '2000.00'},
|
||||
};
|
||||
|
||||
test('parses the calendar, keeping every amount as a string', () {
|
||||
final c = IncomeCalendar.fromJson(calendarJson);
|
||||
expect(c.asOf, DateTime.utc(2026, 9, 18));
|
||||
expect(c.totalExpectedRub, '14230.50');
|
||||
expect(c.byBasis['history'], '2000.00');
|
||||
expect(c.entries.single.basis, 'announced');
|
||||
expect(c.entries.single.ticker, 'SBER');
|
||||
expect(c.entries.single.taxWithheld, isNull);
|
||||
expect(c.entries.single.perUnit, '34.84', reason: 'never parsed through double');
|
||||
});
|
||||
|
||||
test('a null amount_rub stays null — no FX rate is not zero', () {
|
||||
final json = Map<String, dynamic>.from(calendarJson);
|
||||
json['entries'] = [
|
||||
{...(calendarJson['entries'] as List).first as Map<String, dynamic>, 'amount_rub': null},
|
||||
];
|
||||
expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull);
|
||||
});
|
||||
|
||||
test('parses the history rows and totals', () {
|
||||
final h = IncomeHistory.fromJson(const {
|
||||
'rows': [
|
||||
{
|
||||
'month': '2026-08-01',
|
||||
'kind': 'coupon',
|
||||
'currency': 'RUB',
|
||||
'amount': '1204.11',
|
||||
'amount_rub': '1204.11',
|
||||
'tax_withheld': '156.00',
|
||||
'payment_count': 3,
|
||||
},
|
||||
],
|
||||
'totals': {'amount_rub': '24518.30', 'tax_withheld_rub': '3187.00'},
|
||||
});
|
||||
expect(h.rows.single.month, DateTime.utc(2026, 8));
|
||||
expect(h.rows.single.paymentCount, 3);
|
||||
expect(h.totalRub, '24518.30');
|
||||
expect(h.taxWithheldRub, '3187.00');
|
||||
});
|
||||
|
||||
test('parses the forecast with its by_basis split and null yield', () {
|
||||
final f = IncomeForecast.fromJson(const {
|
||||
'months': [
|
||||
{
|
||||
'month': '2026-10-01',
|
||||
'amount_rub': '1830.20',
|
||||
'by_basis': {'schedule': '1133.40', 'announced': '696.80', 'history': '0'},
|
||||
},
|
||||
],
|
||||
'total_rub': '21960.00',
|
||||
'annual_yield_on_value': '0.081',
|
||||
'warnings': ['у 3 инструментов нет истории выплат — в прогноз не вошли'],
|
||||
});
|
||||
expect(f.months.single.byBasis['schedule'], '1133.40');
|
||||
expect(f.totalRub, '21960.00');
|
||||
expect(f.annualYieldOnValue, '0.081');
|
||||
expect(f.warnings, hasLength(1));
|
||||
expect(f.bases, ['schedule', 'announced', 'history'], reason: 'contract order');
|
||||
|
||||
final noYield = IncomeForecast.fromJson(const {
|
||||
'months': <dynamic>[],
|
||||
'total_rub': '0',
|
||||
'annual_yield_on_value': null,
|
||||
});
|
||||
expect(noYield.annualYieldOnValue, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rebalance', () {
|
||||
test('parses a target set and validates the sum exactly', () {
|
||||
final set = TargetSet.fromJson(const {
|
||||
'dimension': 'asset_class',
|
||||
'weights_sum': '1.00',
|
||||
'targets': [
|
||||
{'bucket': 'share', 'target_weight': '0.60', 'band': '0.05', 'note': null},
|
||||
{'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'},
|
||||
{'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'},
|
||||
],
|
||||
});
|
||||
expect(set.targets, hasLength(3));
|
||||
expect(set.localSum, Decimal.one);
|
||||
expect(set.sumIsValid, isTrue);
|
||||
expect(set.targets.first.band, '0.05');
|
||||
|
||||
// 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double
|
||||
final tenths = TargetSet(dimension: 'asset_class', targets: const [
|
||||
TargetWeight(bucket: 'a', targetWeight: '0.1'),
|
||||
TargetWeight(bucket: 'b', targetWeight: '0.2'),
|
||||
TargetWeight(bucket: 'c', targetWeight: '0.7'),
|
||||
]);
|
||||
expect(tenths.sumIsValid, isTrue);
|
||||
|
||||
final short = TargetSet(dimension: 'asset_class', targets: const [
|
||||
TargetWeight(bucket: 'a', targetWeight: '0.9'),
|
||||
]);
|
||||
expect(short.sumIsValid, isFalse);
|
||||
});
|
||||
|
||||
test('the PUT body omits empty optionals and keeps shares as strings', () {
|
||||
final body = const TargetWeight(bucket: 'bond', targetWeight: '0.30', band: '0.05')
|
||||
.toJson();
|
||||
expect(body['target_weight'], '0.30');
|
||||
expect(body.containsKey('note'), isFalse);
|
||||
});
|
||||
|
||||
test('percent input round-trips through shares without float error', () {
|
||||
expect(percentTextToShare('60'), '0.6');
|
||||
expect(percentTextToShare('33,33'), '0.3333');
|
||||
expect(shareToPercentText('0.3333'), '33,33');
|
||||
expect(shareToPercentText('0.60'), '60');
|
||||
expect(percentTextToShare('abc'), isNull);
|
||||
expect(formatShareAsPercent('0.032', signed: true), '+3,20 %');
|
||||
expect(formatShareAsPercent(null), '—');
|
||||
});
|
||||
|
||||
test('parses the rebalance plan, including within_band and blocked_by_cash', () {
|
||||
final plan = RebalancePlan.fromJson(const {
|
||||
'portfolio_id': 1,
|
||||
'dimension': 'asset_class',
|
||||
'as_of': '2026-09-18',
|
||||
'total_value_rub': '1284300.00',
|
||||
'cash_available_rub': '48120.87',
|
||||
'buckets': [
|
||||
{
|
||||
'bucket': 'share',
|
||||
'current_value_rub': '812000.00',
|
||||
'current_weight': '0.632',
|
||||
'target_weight': '0.60',
|
||||
'drift': '0.032',
|
||||
'within_band': true,
|
||||
'delta_value_rub': '-41420.00',
|
||||
'trades': [
|
||||
{
|
||||
'instrument_id': 88,
|
||||
'ticker': 'SBER',
|
||||
'name': 'Сбербанк России',
|
||||
'action': 'sell',
|
||||
'suggested_qty': '150',
|
||||
'lot': 10,
|
||||
'price': '275.89',
|
||||
'price_currency': 'RUB',
|
||||
'amount_rub': '41383.50',
|
||||
'blocked_by_cash': false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'warnings': ['у 2 инструментов нет цены — в рекомендации не вошли'],
|
||||
});
|
||||
|
||||
expect(plan.portfolioId, 1);
|
||||
expect(plan.buckets.single.withinBand, isTrue);
|
||||
expect(plan.everythingWithinBand, isTrue);
|
||||
final trade = plan.buckets.single.trades.single;
|
||||
expect(trade.action, 'sell');
|
||||
expect(trade.suggestedQty, '150');
|
||||
expect(trade.lot, 10);
|
||||
expect(trade.blockedByCash, isFalse);
|
||||
expect(plan.warnings, hasLength(1));
|
||||
});
|
||||
|
||||
test('suggested_qty stays null when there is no price', () {
|
||||
final trade = RebalanceTrade.fromJson(const {
|
||||
'instrument_id': 91,
|
||||
'ticker': 'SIBN6P4',
|
||||
'action': 'buy',
|
||||
'suggested_qty': null,
|
||||
'lot': 1,
|
||||
'price': null,
|
||||
'amount_rub': null,
|
||||
'blocked_by_cash': false,
|
||||
});
|
||||
expect(trade.suggestedQty, isNull, reason: 'null is «нет цены», never 0');
|
||||
expect(trade.price, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('benchmarks', () {
|
||||
test('parses a period row with excess, kind and days_skipped on both sides', () {
|
||||
final rows = [
|
||||
for (final r in const [
|
||||
{
|
||||
'period': '1y',
|
||||
'date_from': '2025-09-18',
|
||||
'date_to': '2026-09-18',
|
||||
'portfolio_twr': '0.184',
|
||||
'portfolio_twr_annualized': '0.184',
|
||||
'portfolio_days_skipped': 3,
|
||||
'benchmarks': [
|
||||
{
|
||||
'benchmark_id': 1,
|
||||
'code': 'MCFTR',
|
||||
'kind': 'total_return',
|
||||
'twr': '0.121',
|
||||
'twr_annualized': '0.121',
|
||||
'days_skipped': 0,
|
||||
'excess': '0.063',
|
||||
},
|
||||
{
|
||||
'benchmark_id': 2,
|
||||
'code': 'IMOEX',
|
||||
'kind': 'price',
|
||||
'twr': '0.084',
|
||||
'days_skipped': 2,
|
||||
'excess': '0.100',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
BenchmarkRow.fromJson(r),
|
||||
];
|
||||
|
||||
final row = rows.single;
|
||||
expect(row.period, '1y');
|
||||
expect(row.portfolioDaysSkipped, 3);
|
||||
expect(row.hasSkippedDays, isTrue);
|
||||
expect(row.benchmarks.first.isPriceIndex, isFalse);
|
||||
expect(row.benchmarks.last.isPriceIndex, isTrue,
|
||||
reason: 'a price index must be markable');
|
||||
expect(row.benchmarks.first.excess, '0.063');
|
||||
});
|
||||
});
|
||||
|
||||
group('goals', () {
|
||||
test('parses a goal and builds a body without the id', () {
|
||||
final goal = Goal.fromJson(const {
|
||||
'id': 3,
|
||||
'name': 'Подушка',
|
||||
'scope': 'account:12',
|
||||
'target_amount': '1000000',
|
||||
'currency': 'RUB',
|
||||
'target_date': '2028-01-01',
|
||||
'monthly_contribution': '30000',
|
||||
'note': null,
|
||||
'archived': false,
|
||||
});
|
||||
expect(goal.id, 3);
|
||||
expect(goal.targetDate, DateTime.utc(2028));
|
||||
final body = goal.toJson();
|
||||
expect(body.containsKey('id'), isFalse);
|
||||
expect(body['target_date'], '2028-01-01');
|
||||
expect(body.containsKey('note'), isFalse);
|
||||
});
|
||||
|
||||
test('parses progress with a projected date', () {
|
||||
final p = GoalProgress.fromJson(const {
|
||||
'goal_id': 3,
|
||||
'as_of': '2026-09-18',
|
||||
'current_value_rub': '412800.00',
|
||||
'target_amount_rub': '1000000.00',
|
||||
'progress': '0.4128',
|
||||
'projected_date': '2027-11-14',
|
||||
'basis': 'xirr',
|
||||
'assumed_rate': '0.142',
|
||||
'monthly_needed_rub': '42300.00',
|
||||
'on_track': false,
|
||||
});
|
||||
expect(p.projectedDate, DateTime.utc(2027, 11, 14));
|
||||
expect(p.isUnreachable, isFalse);
|
||||
expect(p.basis, 'xirr');
|
||||
expect(p.onTrack, isFalse);
|
||||
});
|
||||
|
||||
test('a null projected_date means «не достигается», not «неизвестно»', () {
|
||||
final p = GoalProgress.fromJson(const {
|
||||
'goal_id': 3,
|
||||
'progress': '0.05',
|
||||
'projected_date': null,
|
||||
'basis': 'contribution',
|
||||
'monthly_needed_rub': '99000.00',
|
||||
'on_track': false,
|
||||
});
|
||||
expect(p.projectedDate, isNull);
|
||||
expect(p.isUnreachable, isTrue);
|
||||
|
||||
// basis "none" is the other case: nothing to project from at all
|
||||
final noBasis = GoalProgress.fromJson(const {
|
||||
'goal_id': 4,
|
||||
'projected_date': null,
|
||||
'basis': 'none',
|
||||
'on_track': false,
|
||||
});
|
||||
expect(noBasis.isUnreachable, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('tax', () {
|
||||
test('parses the year summary, keeping estimated и disclaimer', () {
|
||||
final s = TaxSummary.fromJson(const {
|
||||
'year': 2026,
|
||||
'estimated': true,
|
||||
'tax_rate': '0.13',
|
||||
'accounts': [
|
||||
{
|
||||
'account_id': 12,
|
||||
'account_name': 'ИИС Сбер',
|
||||
'dividends_gross_rub': '12400.00',
|
||||
'coupons_gross_rub': '8100.00',
|
||||
'tax_withheld_rub': '2665.00',
|
||||
'realized_gain_rub': '31200.00',
|
||||
'realized_loss_rub': '-4100.00',
|
||||
'ldv_exempt_rub': '12000.00',
|
||||
'taxable_base_rub': '15100.00',
|
||||
'estimated_tax_rub': '1963.00',
|
||||
},
|
||||
],
|
||||
'totals': {
|
||||
'dividends_gross_rub': '12400.00',
|
||||
'estimated_tax_rub': '1963.00',
|
||||
},
|
||||
'disclaimer': 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.',
|
||||
});
|
||||
|
||||
expect(s.year, 2026);
|
||||
expect(s.estimated, isTrue);
|
||||
expect(s.taxRate, '0.13');
|
||||
expect(s.accounts.single.accountName, 'ИИС Сбер');
|
||||
expect(s.accounts.single.realizedLossRub, '-4100.00');
|
||||
expect(s.totals?.estimatedTaxRub, '1963.00');
|
||||
expect(s.disclaimer, contains('Оценка'));
|
||||
});
|
||||
|
||||
test('estimated defaults to true when the field is missing', () {
|
||||
expect(TaxSummary.fromJson(const {'year': 2026}).estimated, isTrue);
|
||||
});
|
||||
|
||||
test('parses lots and flags the ones close to ЛДВ', () {
|
||||
final lots = [
|
||||
for (final l in const [
|
||||
{
|
||||
'lot_id': 812,
|
||||
'instrument_id': 88,
|
||||
'ticker': 'SBER',
|
||||
'account_id': 12,
|
||||
'open_date': '2024-03-14',
|
||||
'qty_remaining': '20',
|
||||
'cost_rub': '4800.00',
|
||||
'market_value_rub': '5517.80',
|
||||
'unrealized_gain_rub': '717.80',
|
||||
'ldv_eligible': false,
|
||||
'ldv_date': '2027-03-14',
|
||||
'days_to_ldv': 177,
|
||||
'tax_if_sold_now_rub': '93.31',
|
||||
},
|
||||
{
|
||||
'lot_id': 813,
|
||||
'ticker': 'LKOH',
|
||||
'ldv_eligible': false,
|
||||
'days_to_ldv': 400,
|
||||
},
|
||||
{
|
||||
'lot_id': 814,
|
||||
'ticker': 'GAZP',
|
||||
'ldv_eligible': true,
|
||||
'days_to_ldv': 0,
|
||||
},
|
||||
])
|
||||
TaxLot.fromJson(l),
|
||||
];
|
||||
|
||||
expect(lots[0].ldvDate, DateTime.utc(2027, 3, 14));
|
||||
expect(lots[0].daysToLdv, 177);
|
||||
expect(lots[0].nearLdv, isTrue, reason: '177 дн. — меньше полугода');
|
||||
expect(lots[1].nearLdv, isFalse);
|
||||
expect(lots[2].nearLdv, isFalse, reason: 'ЛДВ уже действует');
|
||||
expect(lots[0].taxIfSoldNowRub, '93.31');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user