/// Hand-written client for `/api/v1/income`. /// /// **Temporary.** The income routes are not in `openapi/openapi.json` yet, so the generated /// package `app/packages/api_client` knows nothing about them. Models and calls here follow /// `docs/ai/phase4-contract.md` §1 literally and are meant to be **replaced by the /// generated client** as soon as the routes land in the spec and `just gen-client` runs. /// /// Raw Dio comes from `ref.read(apiProvider).dio`, which already carries the base URL, the /// bearer header and the single transparent refresh on 401. library; import 'package:dio/dio.dart'; import '../../../core/utils/json.dart'; /// One expected (or already paid) payment. /// /// [basis] is not decoration: `schedule` is arithmetic over a published schedule, /// `announced` is a fact the issuer declared, `history` is an extrapolation that can be /// wrong by any amount, and `paid` already happened. The screen must show it on every row. class IncomeEntry { const IncomeEntry({ required this.kind, required this.basis, required this.currency, this.instrumentId, this.ticker, this.name, this.expectedDate, this.recordDate, this.qty, this.perUnit, this.amount, this.amountRub, this.taxWithheld, }); final int? instrumentId; final String? ticker; final String? name; /// `dividend | coupon | amortization | repayment` — a plain string, like every stable key. final String kind; final DateTime? expectedDate; final DateTime? recordDate; final String? qty; final String? perUnit; final String? amount; final String currency; /// Null when there is no FX rate for the date — not zero. final String? amountRub; /// `schedule | announced | history | paid`. final String basis; final String? taxWithheld; String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); static IncomeEntry fromJson(Map json) => IncomeEntry( instrumentId: asInt(json['instrument_id']), ticker: asString(json['ticker']), name: asString(json['name']), kind: asString(json['kind']) ?? 'dividend', expectedDate: asDate(json['expected_date']), recordDate: asDate(json['record_date']), qty: asString(json['qty']), perUnit: asString(json['per_unit']), amount: asString(json['amount']), currency: asString(json['currency']) ?? 'RUB', amountRub: asString(json['amount_rub']), basis: asString(json['basis']) ?? 'history', taxWithheld: asString(json['tax_withheld']), ); } class IncomeCalendar { const IncomeCalendar({ required this.totalExpectedRub, required this.currency, this.asOf, this.entries = const [], this.byBasis = const {}, }); final DateTime? asOf; final String currency; final String totalExpectedRub; final List entries; final Map byBasis; static IncomeCalendar fromJson(Map json) => IncomeCalendar( asOf: asDate(json['as_of']), currency: asString(json['currency']) ?? 'RUB', totalExpectedRub: asString(json['total_expected_rub']) ?? '0', entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(), byBasis: asStringMap(json['by_basis']), ); } class IncomeHistoryRow { const IncomeHistoryRow({ required this.kind, required this.currency, required this.amount, this.month, this.amountRub, this.taxWithheld, this.paymentCount = 0, }); final DateTime? month; final String kind; final String currency; final String amount; final String? amountRub; final String? taxWithheld; final int paymentCount; static IncomeHistoryRow fromJson(Map json) => IncomeHistoryRow( month: asDate(json['month']), kind: asString(json['kind']) ?? 'other', currency: asString(json['currency']) ?? 'RUB', amount: asString(json['amount']) ?? '0', amountRub: asString(json['amount_rub']), taxWithheld: asString(json['tax_withheld']), paymentCount: asInt(json['payment_count']) ?? 0, ); } class IncomeHistory { const IncomeHistory({ this.rows = const [], this.totalRub = '0', this.taxWithheldRub = '0', }); final List rows; final String totalRub; final String taxWithheldRub; static IncomeHistory fromJson(Map json) { final totals = asObject(json['totals']) ?? const {}; return IncomeHistory( rows: asObjects(json['rows']).map(IncomeHistoryRow.fromJson).toList(), totalRub: asString(totals['amount_rub']) ?? '0', taxWithheldRub: asString(totals['tax_withheld_rub']) ?? '0', ); } } class ForecastMonth { const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}}); final DateTime? month; final String amountRub; /// The split the total must never hide: a month made of `history` alone is a guess. final Map byBasis; static ForecastMonth fromJson(Map json) => ForecastMonth( month: asDate(json['month']), amountRub: asString(json['amount_rub']) ?? '0', byBasis: asStringMap(json['by_basis']), ); } class IncomeForecast { const IncomeForecast({ required this.totalRub, this.months = const [], this.annualYieldOnValue, this.warnings = const [], }); final List months; final String totalRub; /// Null when the current value is unknown — shown as an em dash, never as 0 %. final String? annualYieldOnValue; final List warnings; /// Every basis present anywhere in the forecast, in contract order. List get bases { const order = ['schedule', 'announced', 'history', 'paid']; final seen = {for (final m in months) ...m.byBasis.keys}; return [ ...order.where(seen.contains), ...seen.where((b) => !order.contains(b)), ]; } static IncomeForecast fromJson(Map json) => IncomeForecast( months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(), totalRub: asString(json['total_rub']) ?? '0', annualYieldOnValue: asString(json['annual_yield_on_value']), warnings: asStrings(json['warnings']), ); } class IncomeApi { const IncomeApi(this._dio); final Dio _dio; static const _base = '/api/v1/income'; Future calendar({ String scope = 'all', DateTime? dateFrom, DateTime? dateTo, bool includePaid = false, }) async { final r = await _dio.get>('$_base/calendar', queryParameters: { 'scope': scope, 'date_from': ?_isoDate(dateFrom), 'date_to': ?_isoDate(dateTo), 'include_paid': includePaid, }); return IncomeCalendar.fromJson(r.data ?? const {}); } Future history({ String scope = 'all', String group = 'month', DateTime? dateFrom, DateTime? dateTo, String? kind, }) async { final r = await _dio.get>('$_base/history', queryParameters: { 'scope': scope, 'group': group, 'date_from': ?_isoDate(dateFrom), 'date_to': ?_isoDate(dateTo), 'kind': ?kind, }); return IncomeHistory.fromJson(r.data ?? const {}); } Future forecast({String scope = 'all', int months = 12}) async { final r = await _dio.get>('$_base/forecast', queryParameters: { 'scope': scope, 'months': months, }); return IncomeForecast.fromJson(r.data ?? const {}); } /// `format: date` on the wire. `DateQueryInterceptor` does this for the generated client; /// this layer builds its query maps itself, so it truncates here. static String? _isoDate(DateTime? d) => d == null ? null : '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; }