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 = { '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.from(calendarJson); json['entries'] = [ {...(calendarJson['entries'] as List).first as Map, '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': [], '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'); }); }); }