Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
283 lines
8.3 KiB
Dart
283 lines
8.3 KiB
Dart
/// 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/cache/cached.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<String, dynamic> 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<IncomeEntry> entries;
|
|
final Map<String, String> byBasis;
|
|
|
|
static IncomeCalendar fromJson(Map<String, dynamic> 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<String, dynamic> 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<IncomeHistoryRow> rows;
|
|
final String totalRub;
|
|
final String taxWithheldRub;
|
|
|
|
static IncomeHistory fromJson(Map<String, dynamic> 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<String, String> byBasis;
|
|
|
|
static ForecastMonth fromJson(Map<String, dynamic> 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<ForecastMonth> months;
|
|
final String totalRub;
|
|
|
|
/// Null when the current value is unknown — shown as an em dash, never as 0 %.
|
|
final String? annualYieldOnValue;
|
|
final List<String> warnings;
|
|
|
|
/// Every basis present anywhere in the forecast, in contract order.
|
|
List<String> 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<String, dynamic> 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';
|
|
|
|
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
|
|
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
|
|
Future<Cached<IncomeCalendar>> calendar({
|
|
String scope = 'all',
|
|
DateTime? dateFrom,
|
|
DateTime? dateTo,
|
|
bool includePaid = false,
|
|
}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'$_base/calendar',
|
|
queryParameters: {
|
|
'scope': scope,
|
|
'date_from': ?_isoDate(dateFrom),
|
|
'date_to': ?_isoDate(dateTo),
|
|
'include_paid': includePaid,
|
|
},
|
|
);
|
|
return Cached(
|
|
IncomeCalendar.fromJson(r.data ?? const {}),
|
|
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
|
);
|
|
}
|
|
|
|
Future<Cached<IncomeHistory>> history({
|
|
String scope = 'all',
|
|
String group = 'month',
|
|
DateTime? dateFrom,
|
|
DateTime? dateTo,
|
|
String? kind,
|
|
}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'$_base/history',
|
|
queryParameters: {
|
|
'scope': scope,
|
|
'group': group,
|
|
'date_from': ?_isoDate(dateFrom),
|
|
'date_to': ?_isoDate(dateTo),
|
|
'kind': ?kind,
|
|
},
|
|
);
|
|
return Cached(
|
|
IncomeHistory.fromJson(r.data ?? const {}),
|
|
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
|
);
|
|
}
|
|
|
|
Future<Cached<IncomeForecast>> forecast({
|
|
String scope = 'all',
|
|
int months = 12,
|
|
}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'$_base/forecast',
|
|
queryParameters: {'scope': scope, 'months': months},
|
|
);
|
|
return Cached(
|
|
IncomeForecast.fromJson(r.data ?? const {}),
|
|
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
|
);
|
|
}
|
|
|
|
/// `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')}';
|
|
}
|