Files
fin-tracker/backend/alembic/versions/0506c15002c8_phase1_zenmoney_fx_metrics.py
T
Dmitry c55fe19e48 feat(sources): контракт источников, worker и синк ZenMoney + ЦБ
Source.sync(ctx) -> SyncResult пишет только raw_* и возвращает курсор; локи,
журнал, ошибки и продвижение курсора берёт на себя worker/runner.

ZenMoney читается единственным доступным способом — POST /v8/diff/ по
serverTimestamp; токен живёт сутки, поэтому worker ротирует refresh_token через
source_credential. Маппер всегда пересобирает core из полных raw_*, так что
удаление в ZenMoney исчезает и у нас.

ЦБ ходит мимо прокси (trust_env=False) и отдаёт cp1251 с делением на Nominal.
Курсы только по рабочим дням — протяжку по календарю делает аналитика.

Планировщик — APScheduler в отдельном процессе, на источник advisory-лок
sync:<name>, чтобы ручной запуск не пересёкся с плановым.
2026-09-18 13:43:49 +03:00

459 lines
18 KiB
Python

"""phase1 zenmoney fx metrics
Revision ID: 0506c15002c8
Revises: 05beabc436ad
Create Date: 2026-09-17 22:38:27.588642
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0506c15002c8"
down_revision: str | None = "05beabc436ad"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"category",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("source_id", sa.String(length=64), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("name", sa.String(length=256), nullable=False),
sa.Column("icon", sa.String(length=64), nullable=True),
sa.Column("color", sa.BigInteger(), nullable=True),
sa.Column("show_income", sa.Boolean(), nullable=False),
sa.Column("show_outcome", sa.Boolean(), nullable=False),
sa.Column("archived", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["parent_id"],
["category.id"],
name=op.f("fk_category_parent_id_category"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_category")),
sa.UniqueConstraint("source", "source_id", name=op.f("uq_category_source_source_id")),
)
op.create_table(
"fx_rate_daily",
sa.Column("d", sa.Date(), nullable=False),
sa.Column("ccy", sa.String(length=3), nullable=False),
sa.Column("rate_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("source", sa.String(length=16), nullable=False),
sa.Column("is_carried", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("d", "ccy", name=op.f("pk_fx_rate_daily")),
)
op.create_table(
"merchant",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("source_id", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=256), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_merchant")),
sa.UniqueConstraint("source", "source_id", name=op.f("uq_merchant_source_source_id")),
)
op.create_table(
"metric_cash_flow_monthly",
sa.Column("month", sa.Date(), nullable=False),
sa.Column("income_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("expense_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("baseline_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("one_off_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("savings_transfer_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("savings_rate", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("txn_count", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("month", name=op.f("pk_metric_cash_flow_monthly")),
)
op.create_table(
"metric_data_quality",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("check_name", sa.String(length=64), nullable=False),
sa.Column("severity", sa.String(length=8), nullable=False),
sa.Column("detail", sa.Text(), nullable=False),
sa.Column("count", sa.Integer(), nullable=False),
sa.Column("ref", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_data_quality")),
)
op.create_index(
op.f("ix_metric_data_quality_check_name"),
"metric_data_quality",
["check_name"],
unique=False,
)
op.create_table(
"metric_net_worth_daily",
sa.Column("d", sa.Date(), nullable=False),
sa.Column("total_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("liquid_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("savings_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("investment_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("debt_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("by_currency", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("missing_fx_count", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("d", name=op.f("pk_metric_net_worth_daily")),
)
op.create_table(
"metric_refresh_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("trigger", sa.String(length=32), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_refresh_log")),
)
op.create_table(
"metric_runway",
sa.Column("as_of", sa.Date(), nullable=False),
sa.Column("liquid_reserve_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("avg_baseline_3m_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("runway_months", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("as_of", name=op.f("pk_metric_runway")),
)
op.create_table(
"raw_cbr_rate",
sa.Column("rate_date", sa.Date(), nullable=False),
sa.Column("ccy", sa.String(length=3), nullable=False),
sa.Column("nominal", sa.Integer(), nullable=False),
sa.Column("value", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column(
"fetched_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("rate_date", "ccy", name=op.f("pk_raw_cbr_rate")),
)
op.create_table(
"raw_zenmoney_deletion",
sa.Column("entity_type", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("stamp", sa.BigInteger(), nullable=True),
sa.Column(
"deleted_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("entity_type", "id", name=op.f("pk_raw_zenmoney_deletion")),
)
op.create_table(
"raw_zenmoney_entity",
sa.Column("entity_type", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("changed", sa.BigInteger(), nullable=True),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column(
"ingested_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("entity_type", "id", name=op.f("pk_raw_zenmoney_entity")),
)
op.create_table(
"rule",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"kind",
sa.Enum(
"savings",
"one_off",
"category",
"payee",
"broker_target",
"ignore",
name="rule_kind",
),
nullable=False,
),
sa.Column(
"match_type",
sa.Enum("id", "payee", "comment", "category", "mcc", "account", name="rule_match_type"),
nullable=False,
),
sa.Column("pattern", sa.String(length=512), nullable=False),
sa.Column("value", sa.String(length=512), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("last_matched_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("match_count", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_rule")),
)
op.create_table(
"trip",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=256), nullable=False),
sa.Column("date_from", sa.Date(), nullable=False),
sa.Column("date_to", sa.Date(), nullable=False),
sa.Column("country", sa.String(length=2), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_trip")),
)
op.create_table(
"cash_txn",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("source_id", sa.String(length=64), nullable=False),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("income", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("income_currency", sa.String(length=3), nullable=True),
sa.Column("income_account_id", sa.Integer(), nullable=True),
sa.Column("outcome", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("outcome_currency", sa.String(length=3), nullable=True),
sa.Column("outcome_account_id", sa.Integer(), nullable=True),
sa.Column("op_income", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("op_income_currency", sa.String(length=3), nullable=True),
sa.Column("op_outcome", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("op_outcome_currency", sa.String(length=3), nullable=True),
sa.Column("payee", sa.String(length=512), nullable=True),
sa.Column("original_payee", sa.String(length=512), nullable=True),
sa.Column("merchant_id", sa.Integer(), nullable=True),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("mcc", sa.Integer(), nullable=True),
sa.Column("hold", sa.Boolean(), nullable=False),
sa.Column("deleted", sa.Boolean(), nullable=False),
sa.Column("changed", sa.BigInteger(), nullable=True),
sa.Column("primary_category_id", sa.Integer(), nullable=True),
sa.Column(
"flow_type",
sa.Enum(
"income",
"expense",
"internal_transfer",
"savings_transfer",
"broker_external_flow",
"deleted",
"other",
name="flow_type",
),
nullable=False,
),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("payee_canonical", sa.String(length=512), nullable=True),
sa.Column("is_one_off", sa.Boolean(), nullable=False),
sa.Column("trip_id", sa.Integer(), nullable=True),
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["category_id"],
["category.id"],
name=op.f("fk_cash_txn_category_id_category"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["income_account_id"],
["account.id"],
name=op.f("fk_cash_txn_income_account_id_account"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["merchant_id"],
["merchant.id"],
name=op.f("fk_cash_txn_merchant_id_merchant"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["outcome_account_id"],
["account.id"],
name=op.f("fk_cash_txn_outcome_account_id_account"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["primary_category_id"],
["category.id"],
name=op.f("fk_cash_txn_primary_category_id_category"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["trip_id"], ["trip.id"], name=op.f("fk_cash_txn_trip_id_trip"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_cash_txn")),
sa.UniqueConstraint("source", "source_id", name=op.f("uq_cash_txn_source_source_id")),
)
op.create_index(op.f("ix_cash_txn_date"), "cash_txn", ["date"], unique=False)
op.create_index(
op.f("ix_cash_txn_income_account_id"), "cash_txn", ["income_account_id"], unique=False
)
op.create_index(
op.f("ix_cash_txn_outcome_account_id"), "cash_txn", ["outcome_account_id"], unique=False
)
op.create_table(
"metric_spending_by_category",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("month", sa.Date(), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("root_category_id", sa.Integer(), nullable=True),
sa.Column("amount_rub", sa.Numeric(precision=24, scale=10), nullable=False),
sa.Column("txn_count", sa.Integer(), nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["category_id"],
["category.id"],
name=op.f("fk_metric_spending_by_category_category_id_category"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["root_category_id"],
["category.id"],
name=op.f("fk_metric_spending_by_category_root_category_id_category"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_metric_spending_by_category")),
sa.UniqueConstraint(
"month",
"category_id",
name=op.f("uq_metric_spending_by_category_month_category_id"),
postgresql_nulls_not_distinct=True,
),
)
op.create_index(
op.f("ix_metric_spending_by_category_month"),
"metric_spending_by_category",
["month"],
unique=False,
)
op.create_table(
"cash_txn_tag",
sa.Column("txn_id", sa.Integer(), nullable=False),
sa.Column("ord", sa.Integer(), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["category_id"],
["category.id"],
name=op.f("fk_cash_txn_tag_category_id_category"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["txn_id"],
["cash_txn.id"],
name=op.f("fk_cash_txn_tag_txn_id_cash_txn"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("txn_id", "ord", name=op.f("pk_cash_txn_tag")),
)
op.create_index(
op.f("ix_cash_txn_tag_category_id"), "cash_txn_tag", ["category_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_cash_txn_tag_category_id"), table_name="cash_txn_tag")
op.drop_table("cash_txn_tag")
op.drop_index(
op.f("ix_metric_spending_by_category_month"), table_name="metric_spending_by_category"
)
op.drop_table("metric_spending_by_category")
op.drop_index(op.f("ix_cash_txn_outcome_account_id"), table_name="cash_txn")
op.drop_index(op.f("ix_cash_txn_income_account_id"), table_name="cash_txn")
op.drop_index(op.f("ix_cash_txn_date"), table_name="cash_txn")
op.drop_table("cash_txn")
op.drop_table("trip")
op.drop_table("rule")
op.drop_table("raw_zenmoney_entity")
op.drop_table("raw_zenmoney_deletion")
op.drop_table("raw_cbr_rate")
op.drop_table("metric_runway")
op.drop_table("metric_refresh_log")
op.drop_table("metric_net_worth_daily")
op.drop_index(op.f("ix_metric_data_quality_check_name"), table_name="metric_data_quality")
op.drop_table("metric_data_quality")
op.drop_table("metric_cash_flow_monthly")
op.drop_table("merchant")
op.drop_table("fx_rate_daily")
op.drop_table("category")
# ### end Alembic commands ###
for enum_name in ("flow_type", "rule_kind", "rule_match_type"):
sa.Enum(name=enum_name).drop(op.get_bind(), checkfirst=True)