accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
176 lines
6.0 KiB
Dart
176 lines
6.0 KiB
Dart
/// Hand-written client for `/api/v1/tax`.
|
|
///
|
|
/// **Temporary.** The tax routes are not in `openapi/openapi.json` yet, so
|
|
/// `app/packages/api_client` has no generated models or methods for them. Everything here
|
|
/// follows `docs/ai/phase4-contract.md` §5 literally and is meant to be **replaced by the
|
|
/// generated client** once the routes land in the spec and `just gen-client` runs.
|
|
///
|
|
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot
|
|
/// refresh on 401 are already wired there.
|
|
library;
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
import '../../../core/cache/cached.dart';
|
|
import '../../../core/utils/json.dart';
|
|
|
|
/// Per-account (or total) tax figures for a year. Every number is an **estimate**: the tax
|
|
/// agent is the broker, and these exist so that the broker's statement can be checked.
|
|
class TaxRow {
|
|
const TaxRow({
|
|
this.accountId,
|
|
this.accountName,
|
|
this.dividendsGrossRub,
|
|
this.couponsGrossRub,
|
|
this.taxWithheldRub,
|
|
this.realizedGainRub,
|
|
this.realizedLossRub,
|
|
this.ldvExemptRub,
|
|
this.taxableBaseRub,
|
|
this.estimatedTaxRub,
|
|
});
|
|
|
|
final int? accountId;
|
|
final String? accountName;
|
|
final String? dividendsGrossRub;
|
|
final String? couponsGrossRub;
|
|
final String? taxWithheldRub;
|
|
final String? realizedGainRub;
|
|
final String? realizedLossRub;
|
|
final String? ldvExemptRub;
|
|
final String? taxableBaseRub;
|
|
final String? estimatedTaxRub;
|
|
|
|
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
|
|
|
|
static TaxRow fromJson(Map<String, dynamic> json) => TaxRow(
|
|
accountId: asInt(json['account_id']),
|
|
accountName: asString(json['account_name']),
|
|
dividendsGrossRub: asString(json['dividends_gross_rub']),
|
|
couponsGrossRub: asString(json['coupons_gross_rub']),
|
|
taxWithheldRub: asString(json['tax_withheld_rub']),
|
|
realizedGainRub: asString(json['realized_gain_rub']),
|
|
realizedLossRub: asString(json['realized_loss_rub']),
|
|
ldvExemptRub: asString(json['ldv_exempt_rub']),
|
|
taxableBaseRub: asString(json['taxable_base_rub']),
|
|
estimatedTaxRub: asString(json['estimated_tax_rub']),
|
|
);
|
|
}
|
|
|
|
class TaxSummary {
|
|
const TaxSummary({
|
|
required this.year,
|
|
required this.estimated,
|
|
this.taxRate,
|
|
this.accounts = const [],
|
|
this.totals,
|
|
this.disclaimer,
|
|
});
|
|
|
|
final int year;
|
|
|
|
/// Always true per the contract — and shown on screen, not hidden in a tooltip.
|
|
final bool estimated;
|
|
final String? taxRate;
|
|
final List<TaxRow> accounts;
|
|
final TaxRow? totals;
|
|
final String? disclaimer;
|
|
|
|
static const defaultDisclaimer =
|
|
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.';
|
|
|
|
static TaxSummary fromJson(Map<String, dynamic> json) {
|
|
final totals = asObject(json['totals']);
|
|
return TaxSummary(
|
|
year: asInt(json['year']) ?? DateTime.now().year,
|
|
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true,
|
|
taxRate: asString(json['tax_rate']),
|
|
accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(),
|
|
totals: totals == null ? null : TaxRow.fromJson(totals),
|
|
disclaimer: asString(json['disclaimer']),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An open lot with the date after which a sale falls under ЛДВ (the three-year exemption).
|
|
class TaxLot {
|
|
const TaxLot({
|
|
required this.lotId,
|
|
required this.ldvEligible,
|
|
this.instrumentId,
|
|
this.ticker,
|
|
this.accountId,
|
|
this.openDate,
|
|
this.qtyRemaining,
|
|
this.costRub,
|
|
this.marketValueRub,
|
|
this.unrealizedGainRub,
|
|
this.ldvDate,
|
|
this.daysToLdv,
|
|
this.taxIfSoldNowRub,
|
|
});
|
|
|
|
final int lotId;
|
|
final int? instrumentId;
|
|
final String? ticker;
|
|
final int? accountId;
|
|
final DateTime? openDate;
|
|
final String? qtyRemaining;
|
|
final String? costRub;
|
|
final String? marketValueRub;
|
|
final String? unrealizedGainRub;
|
|
final bool ldvEligible;
|
|
final DateTime? ldvDate;
|
|
final int? daysToLdv;
|
|
final String? taxIfSoldNowRub;
|
|
|
|
/// Close enough to ЛДВ that selling now is an expensive mistake. Six months is the
|
|
/// horizon at which a person can still decide to wait.
|
|
bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183;
|
|
|
|
String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId');
|
|
|
|
static TaxLot fromJson(Map<String, dynamic> json) => TaxLot(
|
|
lotId: asInt(json['lot_id']) ?? 0,
|
|
instrumentId: asInt(json['instrument_id']),
|
|
ticker: asString(json['ticker']),
|
|
accountId: asInt(json['account_id']),
|
|
openDate: asDate(json['open_date']),
|
|
qtyRemaining: asString(json['qty_remaining']),
|
|
costRub: asString(json['cost_rub']),
|
|
marketValueRub: asString(json['market_value_rub']),
|
|
unrealizedGainRub: asString(json['unrealized_gain_rub']),
|
|
ldvEligible: asBool(json['ldv_eligible']),
|
|
ldvDate: asDate(json['ldv_date']),
|
|
daysToLdv: asInt(json['days_to_ldv']),
|
|
taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']),
|
|
);
|
|
}
|
|
|
|
class TaxApi {
|
|
const TaxApi(this._dio);
|
|
|
|
final Dio _dio;
|
|
|
|
static const _base = '/api/v1/tax';
|
|
|
|
/// 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<TaxSummary>> summary({required int year, int? accountId}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
_base,
|
|
queryParameters: {'year': year, 'account_id': ?accountId},
|
|
);
|
|
return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
|
}
|
|
|
|
Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async {
|
|
final r = await _dio.get<Map<String, dynamic>>(
|
|
'$_base/lots',
|
|
queryParameters: {'year': year, 'account_id': ?accountId},
|
|
);
|
|
final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList();
|
|
return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
|
}
|
|
}
|