"""Schemas for `/imports` and `/instruments/pending`, per `docs/ai/import-contract.md`. Two conventions the Flutter client depends on, both repeated from AGENTS.md because breaking either one breaks the generated Dart rather than a test: * money and quantities are `Money` / `MoneyOpt` — `Decimal` in Python, a string on the wire; * every stable key (`broker`, `parse_status`, `kind`, `status`, `asset_class`) is a plain `str`, never an enum. `AssetClass.index` cannot exist as a Dart enum member (it collides with `Enum.index`) and one enum leaking out stops the whole client from compiling. """ from __future__ import annotations from datetime import date, datetime from typing import Literal from pydantic import BaseModel, Field from fintracker.api.schemas.common import Money, MoneyOpt class AccountSuggestion(BaseModel): """A broker account the user may attach an import to when the report's number matched none.""" id: int name: str broker: str | None """tinvest | sber | vtb | other""" source_id: str class ImportCounts(BaseModel): lines: int events_total: int events_new: int """No `event` carries this row's `dedupe_key` yet.""" events_duplicate: int """The key is already in the ledger: committing upserts, it does not insert.""" events_shadow: int """Rows that will land as `shadow` — this feed is not the account's primary source.""" events_pending: int """Rows whose instrument nobody recognises yet.""" by_kind: dict[str, int] class PendingInstrumentOut(BaseModel): id: int source: str source_key: str isin: str | None = None ticker: str | None = None board: str | None = None name: str | None = None currency: str | None = None asset_class_hint: str | None = None """share | bond | etf | fund | currency — the report's own wording, never applied alone.""" occurrences: int sample_quantity: MoneyOpt = None sample_price: MoneyOpt = None status: str """pending | resolved | ignored""" instrument_id: int | None = None first_seen_file_id: int | None = None created_at: datetime | None = None class ReconciliationPosition(BaseModel): instrument_id: int | None instrument_name: str ticker: str | None isin: str | None qty_report: Money qty_derived: MoneyOpt """Null when the account is unknown or its lots were never rebuilt — never a zero.""" qty_delta: MoneyOpt matches: bool class ReconciliationCash(BaseModel): currency: str balance_report: Money balance_derived: MoneyOpt delta: MoneyOpt matches: bool class ReconciliationOut(BaseModel): as_of: date | None positions: list[ReconciliationPosition] = Field(default_factory=list) cash: list[ReconciliationCash] = Field(default_factory=list) matches: bool = True class SampleEventOut(BaseModel): line_no: int kind: str """buy | sell | dividend | coupon | … — the `EventKind` value as a string.""" trade_date: date settle_date: date | None = None instrument_key: str | None = None instrument_name: str | None = None instrument_id: int | None = None quantity: MoneyOpt = None price: MoneyOpt = None amount: Money currency: str fee: MoneyOpt = None trade_no: str | None = None dedupe_key: str is_duplicate: bool description: str | None = None class ImportSummary(BaseModel): """One import without the heavy parts — what `GET /imports` lists.""" id: int broker: str """sber | vtb | csv""" filename: str sha256: str size_bytes: int parser_name: str | None = None parser_version: str | None = None parse_status: str """uploaded | parsed | committed | failed""" error: str | None = None duplicate_of_id: int | None = None """Not null ⇒ these bytes were already uploaded, and this is that import.""" account_external_id: str | None = None account_id: int | None = None """Null ⇒ the account is unknown and commit is refused.""" account_name: str | None = None period_from: date | None = None period_to: date | None = None uploaded_at: datetime | None = None committed_at: datetime | None = None counts: ImportCounts class ImportPreview(ImportSummary): """The full preview: `POST /imports` and `GET /imports/{id}` both return this.""" account_suggestions: list[AccountSuggestion] = Field(default_factory=list) pending_instruments: list[PendingInstrumentOut] = Field(default_factory=list) reconciliation: ReconciliationOut warnings: list[str] = Field(default_factory=list) sample_events: list[SampleEventOut] = Field(default_factory=list) class CommitRequest(BaseModel): account_id: int | None = None """Needed only when the preview could not work the account out on its own.""" confirm_duplicates: bool = False dry_run: bool = False class ImportResult(BaseModel): import_id: int committed: bool events_created: int events_updated: int events_skipped: int """Rows still waiting on a `pending_instrument`.""" events_shadow: int pending_instruments: int reconciliation: ReconciliationOut metrics_refreshed: bool class NewInstrument(BaseModel): """The instrument to create from a report's own data (`action = "create"`).""" asset_class: str """share | bond | etf | fund | currency | index | deposit | real_estate | crypto | custom""" name: str currency: str = "RUB" isin: str | None = None ticker: str | None = None board: str | None = None lot: int = 1 class PendingResolveRequest(BaseModel): action: Literal["link", "create", "ignore"] instrument_id: int | None = None """Required for `link`.""" instrument: NewInstrument | None = None """Required for `create`.""" class PendingResolveResult(BaseModel): id: int status: str """resolved | ignored""" instrument_id: int | None = None events_bound: int """How many events left `pending` for `confirmed`/`shadow`.""" alias_created: bool metrics_refreshed: bool