feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция
SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят значения, а не имена, чтобы база читалась так же, как API. Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job. metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""fintracker — personal finance + investment analytics backend."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,140 @@
|
||||
"""`fintracker` command line: serve / worker / create-user / openapi / sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from fintracker.config import get_settings
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
||||
|
||||
|
||||
def _logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=get_settings().log_level.upper(),
|
||||
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8000,
|
||||
reload: bool = typer.Option(False, "--reload", help="dev only"),
|
||||
) -> None:
|
||||
"""Run the HTTP API."""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("fintracker.api.app:create_app", factory=True, host=host, port=port, reload=reload)
|
||||
|
||||
|
||||
@app.command()
|
||||
def worker() -> None:
|
||||
"""Run the scheduled-sync worker (one instance per deployment)."""
|
||||
_logging()
|
||||
from fintracker.worker.scheduler import serve_forever
|
||||
|
||||
asyncio.run(serve_forever())
|
||||
|
||||
|
||||
@app.command("create-user")
|
||||
def create_user(
|
||||
email: str,
|
||||
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
|
||||
) -> None:
|
||||
"""Create (or reset the password of) the application user."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.security import hash_password
|
||||
from fintracker.db import get_sessionmaker, reset_engine
|
||||
from fintracker.models import AppUser
|
||||
|
||||
async def _run() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.email == email.lower()))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
session.add(AppUser(email=email.lower(), password_hash=hash_password(password)))
|
||||
action = "created"
|
||||
else:
|
||||
user.password_hash = hash_password(password)
|
||||
action = "password reset"
|
||||
await session.commit()
|
||||
await reset_engine()
|
||||
typer.echo(f"{email.lower()}: {action}")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def openapi(out: Annotated[Path, typer.Argument()] = Path("openapi.json")) -> None:
|
||||
"""Write the OpenAPI schema to a file (committed as openapi/openapi.json)."""
|
||||
from fintracker.api.app import create_app
|
||||
|
||||
schema = create_app().openapi()
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(schema, indent=2, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
typer.echo(f"wrote {out}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def sync(source: str) -> None:
|
||||
"""Run one source sync in-process (uses the same lock as the worker)."""
|
||||
_logging()
|
||||
from fintracker.db import reset_engine
|
||||
from fintracker.sources import registry
|
||||
from fintracker.worker.runner import Skipped, run_source
|
||||
|
||||
if source not in registry.names():
|
||||
raise typer.BadParameter(f"unknown source {source!r}; known: {registry.names()}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
run = await run_source(source, triggered_by="cli")
|
||||
except Skipped:
|
||||
typer.echo(f"{source}: already running elsewhere")
|
||||
raise typer.Exit(1) from None
|
||||
finally:
|
||||
await reset_engine()
|
||||
typer.echo(f"{source}: {run.status.value} counts={run.counts} warnings={run.warnings}")
|
||||
if run.error:
|
||||
typer.echo(run.error, err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
metrics_app = typer.Typer(help="Precomputed metrics.")
|
||||
app.add_typer(metrics_app, name="metrics")
|
||||
|
||||
|
||||
@metrics_app.command("refresh")
|
||||
def metrics_refresh() -> None:
|
||||
"""Rebuild every metric_* table from core data (idempotent)."""
|
||||
_logging()
|
||||
from fintracker.db import get_sessionmaker, reset_engine
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
async with get_sessionmaker()() as session:
|
||||
entry = await refresh_all(session, trigger="cli")
|
||||
finally:
|
||||
await reset_engine()
|
||||
if entry.error:
|
||||
typer.echo(entry.error, err=True)
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f"metrics refreshed at {entry.finished_at}")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Runtime settings, read once from the environment (and a local .env in dev)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
"""Repo root: backend/src/fintracker/config.py -> ../../.. — the dev `.env` lives there."""
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
# absolute first: recipes run from `backend/`, so a bare ".env" would never resolve.
|
||||
# A ".env" next to the process (docker image, other layouts) still wins if present.
|
||||
env_file=(_REPO_ROOT / ".env", ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+asyncpg://fintracker:fintracker@localhost:54329/fintracker"
|
||||
"""SQLAlchemy async URL. Inside docker compose the host is `db`."""
|
||||
|
||||
jwt_secret: str = "dev-insecure-secret-change-me"
|
||||
access_token_ttl_seconds: int = 3600
|
||||
refresh_token_ttl_seconds: int = 30 * 86400
|
||||
login_rate_limit_per_minute: int = 5
|
||||
|
||||
cors_origins: str = ""
|
||||
"""Comma-separated origins for the Flutter web build when it is NOT served by Caddy
|
||||
from the same origin (e.g. `flutter run -d chrome` during development)."""
|
||||
|
||||
timezone: str = "Europe/Moscow"
|
||||
uploads_dir: Path = Path("/data/uploads")
|
||||
web_dir: Path | None = None
|
||||
"""Optional Flutter web build to serve at `/` (dev convenience; Caddy does this in prod)."""
|
||||
log_level: str = "INFO"
|
||||
|
||||
base_currency: str = "RUB"
|
||||
"""Display/metric currency; native amounts are always kept alongside."""
|
||||
|
||||
# --- sources: secrets from .env; rotating refresh tokens live in source_credential ---
|
||||
zenmoney_token: str | None = None
|
||||
"""Static access token (e.g. from zerro.app/token). Used when no OAuth client is configured."""
|
||||
zenmoney_client_id: str | None = None
|
||||
zenmoney_client_secret: str | None = None
|
||||
zenmoney_refresh_token: str | None = None
|
||||
"""Initial refresh token for OAuth rotation; later values are stored in source_credential."""
|
||||
tinvest_token: str | None = None
|
||||
|
||||
@field_validator(
|
||||
"web_dir",
|
||||
"zenmoney_token",
|
||||
"zenmoney_client_id",
|
||||
"zenmoney_client_secret",
|
||||
"zenmoney_refresh_token",
|
||||
"tinvest_token",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _blank_is_none(cls, v: object) -> object:
|
||||
"""A blank line in `.env` (`WEB_DIR=`) means unset — not Path('.'), not an empty token."""
|
||||
return None if isinstance(v, str) and not v.strip() else v
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_dev_secret(self) -> bool:
|
||||
return self.jwt_secret.startswith("dev-insecure")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,4 @@
|
||||
from fintracker.db.base import Base, TimestampMixin
|
||||
from fintracker.db.engine import get_engine, get_sessionmaker, reset_engine
|
||||
|
||||
__all__ = ["Base", "TimestampMixin", "get_engine", "get_sessionmaker", "reset_engine"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Declarative base shared by every model.
|
||||
|
||||
Conventions (see plan §1): money and quantities are NUMERIC(24,10) with a currency
|
||||
column next to them; timestamps are timezone-aware; JSON payloads are JSONB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Enum, MetaData, Numeric, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
MONEY = Numeric(24, 10)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
type_annotation_map = { # noqa: RUF012 — SQLAlchemy reads this class attribute
|
||||
Decimal: MONEY,
|
||||
datetime: DateTime(timezone=True),
|
||||
dict[str, Any]: JSONB,
|
||||
list[Any]: JSONB,
|
||||
}
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
def db_enum(enum_cls: type[enum.Enum], name: str) -> Enum:
|
||||
"""Postgres enum that stores member *values* (not names), so the DB reads like the API."""
|
||||
return Enum(enum_cls, name=name, values_callable=lambda e: [m.value for m in e])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Lazily created async engine + session factory.
|
||||
|
||||
Created on first use (not at import) so tests and the CLI can point DATABASE_URL
|
||||
anywhere before anything touches the database. `reset_engine()` drops the cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from fintracker.config import get_settings
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
global _sessionmaker
|
||||
if _sessionmaker is None:
|
||||
_sessionmaker = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||
return _sessionmaker
|
||||
|
||||
|
||||
async def reset_engine() -> None:
|
||||
"""Dispose the cached engine (tests, or after settings change)."""
|
||||
global _engine, _sessionmaker
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_sessionmaker = None
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Rebuild every metric_* table after data changed (plan §3).
|
||||
|
||||
Order (each step is a function taking an AsyncSession and committing its own tables):
|
||||
fx -> classify -> net worth -> cash flow -> spending -> runway -> data quality
|
||||
Phase 2 inserts prices/lots/valuation/holdings/returns between fx and net worth.
|
||||
|
||||
`refresh_all` is what the worker calls after a sync that reported `changed=True`, what the
|
||||
CLI `fintracker metrics refresh` runs, and what `POST /metrics/refresh` queues.
|
||||
|
||||
Every step replaces its whole table, so two refreshes at once would race (a unique violation
|
||||
at best, half of one run's rows at worst). A session-level Postgres advisory lock on its own
|
||||
connection serialises them: a refresh that arrives during a sync WAITS and then runs, because
|
||||
`POST /rules/apply` must take effect, not be silently dropped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fintracker.models import MetricRefreshLog
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LOCK_KEY = "metrics:refresh"
|
||||
|
||||
Step = Callable[[AsyncSession], Awaitable[None]]
|
||||
|
||||
STEPS: list[tuple[str, Step]] = []
|
||||
"""Filled by analytics modules via `register_step`; order of registration == run order."""
|
||||
|
||||
|
||||
def register_step(name: str, step: Step) -> None:
|
||||
STEPS.append((name, step))
|
||||
|
||||
|
||||
async def refresh_all(session: AsyncSession, trigger: str) -> MetricRefreshLog:
|
||||
# imported here (not at module scope) so the analytics import graph stays out of the
|
||||
# worker/CLI import path until a refresh actually runs; registration is idempotent
|
||||
from fintracker.analytics import register_steps
|
||||
from fintracker.db import get_engine
|
||||
from fintracker.worker.locks import advisory_lock
|
||||
|
||||
register_steps()
|
||||
|
||||
# the lock lives on its own connection, so it is independent of `session`'s transactions
|
||||
async with get_engine().connect() as lock_conn, advisory_lock(lock_conn, LOCK_KEY):
|
||||
entry = MetricRefreshLog(trigger=trigger)
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
try:
|
||||
for name, step in STEPS:
|
||||
log.info("metrics: %s", name)
|
||||
await step(session)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
entry.error = f"{type(exc).__name__}: {exc}\n{traceback.format_exc()[-4000:]}"
|
||||
log.exception("metrics refresh failed at step")
|
||||
entry.finished_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return entry
|
||||
@@ -0,0 +1,136 @@
|
||||
"""ORM models. Importing this package registers every table on `Base.metadata`."""
|
||||
|
||||
from fintracker.models.accounts import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountLink,
|
||||
AccountRole,
|
||||
Broker,
|
||||
EventSource,
|
||||
Portfolio,
|
||||
PortfolioAccount,
|
||||
)
|
||||
from fintracker.models.auth import AppUser, RefreshToken
|
||||
from fintracker.models.instruments import AssetClass, Instrument, InstrumentAlias
|
||||
from fintracker.models.ledger import (
|
||||
EXTERNAL_FLOW_KINDS,
|
||||
POSITION_KINDS,
|
||||
Event,
|
||||
EventKind,
|
||||
EventStatus,
|
||||
Lot,
|
||||
LotDisposal,
|
||||
)
|
||||
from fintracker.models.metrics import (
|
||||
MetricCashFlowMonthly,
|
||||
MetricDataQuality,
|
||||
MetricHolding,
|
||||
MetricNetWorthDaily,
|
||||
MetricPortfolioValueDaily,
|
||||
MetricRefreshLog,
|
||||
MetricReturns,
|
||||
MetricRunway,
|
||||
MetricSpendingByCategory,
|
||||
)
|
||||
from fintracker.models.pricing import (
|
||||
CashSnapshot,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
FxRateDaily,
|
||||
PositionSnapshot,
|
||||
PriceDaily,
|
||||
PriceLast,
|
||||
PriceManual,
|
||||
RawCbrRate,
|
||||
)
|
||||
from fintracker.models.sync import (
|
||||
JobStatus,
|
||||
RunStatus,
|
||||
SourceCredential,
|
||||
SyncJob,
|
||||
SyncRun,
|
||||
SyncState,
|
||||
)
|
||||
from fintracker.models.tinvest import (
|
||||
RawTinvestEvent,
|
||||
RawTinvestInstrument,
|
||||
RawTinvestOperation,
|
||||
RawTinvestSnapshot,
|
||||
)
|
||||
from fintracker.models.zenmoney import (
|
||||
CashTxn,
|
||||
CashTxnTag,
|
||||
Category,
|
||||
FlowType,
|
||||
Merchant,
|
||||
RawZenmoneyDeletion,
|
||||
RawZenmoneyEntity,
|
||||
Rule,
|
||||
RuleKind,
|
||||
RuleMatchType,
|
||||
Trip,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EXTERNAL_FLOW_KINDS",
|
||||
"POSITION_KINDS",
|
||||
"Account",
|
||||
"AccountKind",
|
||||
"AccountLink",
|
||||
"AccountRole",
|
||||
"AppUser",
|
||||
"AssetClass",
|
||||
"Broker",
|
||||
"CashSnapshot",
|
||||
"CashTxn",
|
||||
"CashTxnTag",
|
||||
"Category",
|
||||
"CorporateAction",
|
||||
"CorporateActionKind",
|
||||
"CorporateActionStatus",
|
||||
"Event",
|
||||
"EventKind",
|
||||
"EventSource",
|
||||
"EventStatus",
|
||||
"FlowType",
|
||||
"FxRateDaily",
|
||||
"Instrument",
|
||||
"InstrumentAlias",
|
||||
"JobStatus",
|
||||
"Lot",
|
||||
"LotDisposal",
|
||||
"Merchant",
|
||||
"MetricCashFlowMonthly",
|
||||
"MetricDataQuality",
|
||||
"MetricHolding",
|
||||
"MetricNetWorthDaily",
|
||||
"MetricPortfolioValueDaily",
|
||||
"MetricRefreshLog",
|
||||
"MetricReturns",
|
||||
"MetricRunway",
|
||||
"MetricSpendingByCategory",
|
||||
"Portfolio",
|
||||
"PortfolioAccount",
|
||||
"PositionSnapshot",
|
||||
"PriceDaily",
|
||||
"PriceLast",
|
||||
"PriceManual",
|
||||
"RawCbrRate",
|
||||
"RawTinvestEvent",
|
||||
"RawTinvestInstrument",
|
||||
"RawTinvestOperation",
|
||||
"RawTinvestSnapshot",
|
||||
"RawZenmoneyDeletion",
|
||||
"RawZenmoneyEntity",
|
||||
"RefreshToken",
|
||||
"Rule",
|
||||
"RuleKind",
|
||||
"RuleMatchType",
|
||||
"RunStatus",
|
||||
"SourceCredential",
|
||||
"SyncJob",
|
||||
"SyncRun",
|
||||
"SyncState",
|
||||
"Trip",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Accounts, portfolios and the explicit ZenMoney <-> broker account mapping (plan §1.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fintracker.db.base import Base, TimestampMixin, db_enum
|
||||
|
||||
|
||||
class AccountKind(enum.StrEnum):
|
||||
zm_cash = "zm_cash"
|
||||
zm_card = "zm_card"
|
||||
zm_checking = "zm_checking"
|
||||
zm_deposit = "zm_deposit"
|
||||
zm_loan = "zm_loan"
|
||||
zm_emoney = "zm_emoney"
|
||||
zm_debt = "zm_debt"
|
||||
broker = "broker"
|
||||
manual_asset = "manual_asset"
|
||||
|
||||
|
||||
class AccountRole(enum.StrEnum):
|
||||
liquid = "liquid"
|
||||
savings = "savings"
|
||||
investment = "investment"
|
||||
debt = "debt"
|
||||
|
||||
|
||||
class Broker(enum.StrEnum):
|
||||
tinvest = "tinvest"
|
||||
sber = "sber"
|
||||
vtb = "vtb"
|
||||
other = "other"
|
||||
|
||||
|
||||
class EventSource(enum.StrEnum):
|
||||
"""Which feed is the truth for an account's ledger; others become `shadow` events."""
|
||||
|
||||
tinvest_api = "tinvest_api"
|
||||
report_sber = "report_sber"
|
||||
report_vtb = "report_vtb"
|
||||
manual = "manual"
|
||||
|
||||
|
||||
class Account(TimestampMixin, Base):
|
||||
__tablename__ = "account"
|
||||
__table_args__ = (UniqueConstraint("source", "source_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kind: Mapped[AccountKind] = mapped_column(db_enum(AccountKind, "account_kind"))
|
||||
source: Mapped[str] = mapped_column(String(32))
|
||||
"""zenmoney | tinvest | report_sber | report_vtb | manual"""
|
||||
source_id: Mapped[str] = mapped_column(String(128))
|
||||
broker: Mapped[Broker | None] = mapped_column(db_enum(Broker, "broker"))
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
currency: Mapped[str] = mapped_column(String(3))
|
||||
include_in_net_worth: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
role: Mapped[AccountRole] = mapped_column(db_enum(AccountRole, "account_role"))
|
||||
mirror_of_account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="SET NULL")
|
||||
)
|
||||
"""ZenMoney account that merely mirrors a broker account (excluded from net worth)."""
|
||||
primary_event_source: Mapped[EventSource | None] = mapped_column(
|
||||
db_enum(EventSource, "event_source")
|
||||
)
|
||||
opened_at: Mapped[date | None]
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deposit_terms: Mapped[dict[str, Any] | None]
|
||||
"""ZenMoney deposit/loan terms: percent, startDate, endDateOffset, capitalization…"""
|
||||
balance: Mapped[Decimal | None]
|
||||
"""Current balance as the source reports it (ZenMoney `balance`); native currency."""
|
||||
start_balance: Mapped[Decimal | None]
|
||||
credit_limit: Mapped[Decimal | None]
|
||||
balance_as_of: Mapped[datetime | None]
|
||||
"""When `balance` was observed (set by the source sync). Informational only: the
|
||||
net-worth walk in `analytics/networth.py` treats `balance` as the balance of TODAY
|
||||
(`today_local()`) and steps back by transaction DATES, not by this timestamp."""
|
||||
|
||||
|
||||
class Portfolio(TimestampMixin, Base):
|
||||
__tablename__ = "portfolio"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
base_currency: Mapped[str] = mapped_column(String(3), default="RUB")
|
||||
|
||||
|
||||
class PortfolioAccount(Base):
|
||||
__tablename__ = "portfolio_account"
|
||||
|
||||
portfolio_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("portfolio.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class AccountLink(Base):
|
||||
"""Explicit map used by the ZenMoney-transfer <-> broker-deposit matcher."""
|
||||
|
||||
__tablename__ = "account_link"
|
||||
|
||||
zm_account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
broker_account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fintracker.db.base import Base
|
||||
|
||||
|
||||
class AppUser(Base):
|
||||
"""The single user of this deployment. Kept as a table so a second one is not a rewrite."""
|
||||
|
||||
__tablename__ = "app_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""Refresh tokens are stored hashed so logout/revocation is real."""
|
||||
|
||||
__tablename__ = "refresh_token"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_user.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
expires_at: Mapped[datetime]
|
||||
revoked_at: Mapped[datetime | None]
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Instrument master (plan §1.3).
|
||||
|
||||
Identity resolution order: ISIN -> FIGI -> tinvest_uid -> (ticker, board) -> alias.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fintracker.db.base import Base, TimestampMixin, db_enum
|
||||
|
||||
|
||||
class AssetClass(enum.StrEnum):
|
||||
share = "share"
|
||||
bond = "bond"
|
||||
etf = "etf"
|
||||
fund = "fund"
|
||||
currency = "currency"
|
||||
market_index = "index"
|
||||
deposit = "deposit"
|
||||
real_estate = "real_estate"
|
||||
crypto = "crypto"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class Instrument(TimestampMixin, Base):
|
||||
__tablename__ = "instrument"
|
||||
__table_args__ = (
|
||||
Index("uq_instrument_isin", "isin", unique=True, postgresql_where=text("isin IS NOT NULL")),
|
||||
Index("uq_instrument_figi", "figi", unique=True, postgresql_where=text("figi IS NOT NULL")),
|
||||
Index(
|
||||
"uq_instrument_tinvest_uid",
|
||||
"tinvest_uid",
|
||||
unique=True,
|
||||
postgresql_where=text("tinvest_uid IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_instrument_ticker_board",
|
||||
"ticker",
|
||||
"board",
|
||||
unique=True,
|
||||
postgresql_where=text("ticker IS NOT NULL AND board IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
asset_class: Mapped[AssetClass] = mapped_column(db_enum(AssetClass, "asset_class"))
|
||||
isin: Mapped[str | None] = mapped_column(String(12))
|
||||
figi: Mapped[str | None] = mapped_column(String(12))
|
||||
tinvest_uid: Mapped[str | None] = mapped_column(String(64))
|
||||
ticker: Mapped[str | None] = mapped_column(String(32))
|
||||
board: Mapped[str | None] = mapped_column(String(16))
|
||||
"""MOEX board / T-Invest class_code, e.g. TQBR, TQOB."""
|
||||
exchange: Mapped[str | None] = mapped_column(String(32))
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
issuer: Mapped[str | None] = mapped_column(String(256))
|
||||
currency: Mapped[str] = mapped_column(String(3))
|
||||
lot: Mapped[int] = mapped_column(Integer, default=1)
|
||||
nominal: Mapped[Decimal | None]
|
||||
nominal_currency: Mapped[str | None] = mapped_column(String(3))
|
||||
maturity_date: Mapped[date | None]
|
||||
sector: Mapped[str | None] = mapped_column(String(64))
|
||||
country: Mapped[str | None] = mapped_column(String(2))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
meta: Mapped[dict[str, Any] | None]
|
||||
|
||||
|
||||
class InstrumentAlias(Base):
|
||||
"""Source-specific keys resolving to an instrument, e.g. ('report_vtb', 'NAME:Газпром ао')."""
|
||||
|
||||
__tablename__ = "instrument_alias"
|
||||
__table_args__ = (UniqueConstraint("source", "source_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
instrument_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("instrument.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(32))
|
||||
source_key: Mapped[str] = mapped_column(String(256))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Sync bookkeeping: cursors, run log, manual job queue, per-source credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fintracker.db.base import Base, db_enum
|
||||
|
||||
|
||||
class RunStatus(enum.StrEnum):
|
||||
running = "running"
|
||||
ok = "ok"
|
||||
error = "error"
|
||||
|
||||
|
||||
class JobStatus(enum.StrEnum):
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
done = "done"
|
||||
error = "error"
|
||||
|
||||
|
||||
class SyncState(Base):
|
||||
"""One row per source: where the incremental sync left off."""
|
||||
|
||||
__tablename__ = "sync_state"
|
||||
|
||||
source: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
cursor: Mapped[str | None] = mapped_column(Text)
|
||||
last_run_at: Mapped[datetime | None]
|
||||
last_success_at: Mapped[datetime | None]
|
||||
|
||||
|
||||
class SyncRun(Base):
|
||||
__tablename__ = "sync_run"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
source: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[RunStatus] = mapped_column(db_enum(RunStatus, "run_status"))
|
||||
triggered_by: Mapped[str] = mapped_column(String(32))
|
||||
"""schedule | manual | cli"""
|
||||
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
finished_at: Mapped[datetime | None]
|
||||
cursor_before: Mapped[str | None] = mapped_column(Text)
|
||||
cursor_after: Mapped[str | None] = mapped_column(Text)
|
||||
counts: Mapped[dict[str, Any] | None]
|
||||
warnings: Mapped[list[Any] | None]
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class SyncJob(Base):
|
||||
"""Manual trigger queue: API inserts, worker picks up (plan §2.4)."""
|
||||
|
||||
__tablename__ = "sync_job"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
source: Mapped[str] = mapped_column(String(64), index=True)
|
||||
status: Mapped[JobStatus] = mapped_column(db_enum(JobStatus, "job_status"), index=True)
|
||||
requested_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
started_at: Mapped[datetime | None]
|
||||
finished_at: Mapped[datetime | None]
|
||||
run_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("sync_run.id", ondelete="SET NULL"))
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class SourceCredential(Base):
|
||||
"""Rotating credentials a source manages itself (e.g. ZenMoney refresh token).
|
||||
|
||||
Static tokens still come from the environment; this is for what the worker must
|
||||
be able to update unattended.
|
||||
"""
|
||||
|
||||
__tablename__ = "source_credential"
|
||||
|
||||
source: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
payload: Mapped[dict[str, Any]]
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
|
||||
Reference in New Issue
Block a user