From 87b2443a06ca5ea1e808e55c17fd022ee7912910 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 28 Apr 2026 14:33:38 +0300 Subject: [PATCH] feat: add tariff config and database schema --- .env.example | 1 + config/settings.py | 9 ++ config/tariffs.example.json | 49 ++++++++++ config/tariffs_config.py | 173 +++++++++++++++++++++++++++++++++++ db/dal/tariff_dal.py | 97 ++++++++++++++++++++ db/database_setup.py | 54 +++++++++++ db/migrator.py | 99 ++++++++++++++++++++ db/models.py | 61 +++++++++++- tests/test_settings.py | 54 +++++++++++ tests/test_tariffs_config.py | 90 ++++++++++++++++++ 10 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 config/tariffs.example.json create mode 100644 config/tariffs_config.py create mode 100644 db/dal/tariff_dal.py create mode 100644 tests/test_tariffs_config.py diff --git a/.env.example b/.env.example index cfbd28e..900d0d4 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,7 @@ STARS_PRICE_12_MONTHS=0 # Traffic Packages (enables traffic sale mode when set) TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars +TARIFFS_CONFIG_PATH=config/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used. # Subscription Notifications SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription diff --git a/config/settings.py b/config/settings.py index b6d1635..a14a5b1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -5,6 +5,7 @@ from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from config.tariffs_config import TariffsConfig, load_tariffs_config def _split_csv(value: Optional[str]) -> List[str]: @@ -259,6 +260,7 @@ class Settings(BaseSettings): default=None, description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", ) + TARIFFS_CONFIG_PATH: str = Field(default="config/tariffs.json") SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) @@ -731,8 +733,15 @@ class Settings(BaseSettings): @property def traffic_sale_mode(self) -> bool: """When true, the bot sells traffic packages instead of time-based subscriptions.""" + if self.tariffs_config is not None: + return False return bool(self.traffic_packages or self.stars_traffic_packages) + @computed_field + @property + def tariffs_config(self) -> Optional[TariffsConfig]: + return load_tariffs_config(self.TARIFFS_CONFIG_PATH) + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/tariffs.example.json b/config/tariffs.example.json new file mode 100644 index 0000000..db5c4f1 --- /dev/null +++ b/config/tariffs.example.json @@ -0,0 +1,49 @@ +{ + "default_tariff": "standard", + "topup_packages_default": { + "rub": [ + { "gb": 10, "price": 99 }, + { "gb": 50, "price": 399 }, + { "gb": 200, "price": 1299 } + ], + "stars": [ + { "gb": 10, "price": 2500 } + ] + }, + "tariffs": [ + { + "key": "standard", + "names": { "ru": "Стандарт", "en": "Standard" }, + "descriptions": { "ru": "Базовый набор серверов", "en": "Base server pool" }, + "squad_uuids": ["uuid-1", "uuid-2"], + "billing_model": "period", + "monthly_gb": 500, + "prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 }, + "prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 }, + "enabled_periods": [1, 3, 6, 12], + "enabled": true + }, + { + "key": "traffic_basic", + "names": { "ru": "Гигабайты", "en": "Gigabytes" }, + "descriptions": { + "ru": "Пакеты трафика без ограничения по времени", + "en": "Traffic packages with no time limit" + }, + "squad_uuids": ["uuid-1", "uuid-2"], + "billing_model": "traffic", + "conversion_rate_rub_per_gb": 20, + "traffic_packages": { + "rub": [ + { "gb": 10, "price": 199 }, + { "gb": 50, "price": 799 }, + { "gb": 200, "price": 1999 } + ], + "stars": [ + { "gb": 10, "price": 2500 } + ] + }, + "enabled": true + } + ] +} diff --git a/config/tariffs_config.py b/config/tariffs_config.py new file mode 100644 index 0000000..eda0106 --- /dev/null +++ b/config/tariffs_config.py @@ -0,0 +1,173 @@ +import json +import logging +from pathlib import Path +from typing import Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, ValidationError, model_validator + + +Currency = Literal["rub", "stars"] +BillingModel = Literal["period", "traffic"] + + +class TrafficPackage(BaseModel): + gb: float + price: float + + @model_validator(mode="after") + def validate_values(self) -> "TrafficPackage": + if self.gb <= 0: + raise ValueError("package gb must be greater than zero") + if self.price < 0: + raise ValueError("package price must be non-negative") + return self + + +class PackageSet(BaseModel): + rub: List[TrafficPackage] = Field(default_factory=list) + stars: List[TrafficPackage] = Field(default_factory=list) + + def for_currency(self, currency: Currency) -> List[TrafficPackage]: + return list(getattr(self, currency) or []) + + def has_any(self) -> bool: + return bool(self.rub or self.stars) + + +class Tariff(BaseModel): + key: str + names: Dict[str, str] = Field(default_factory=dict) + descriptions: Dict[str, str] = Field(default_factory=dict) + squad_uuids: List[str] = Field(default_factory=list) + billing_model: BillingModel + enabled: bool = True + + monthly_gb: Optional[float] = None + prices_rub: Dict[str, float] = Field(default_factory=dict) + prices_stars: Dict[str, float] = Field(default_factory=dict) + enabled_periods: List[int] = Field(default_factory=list) + topup_packages: Optional[PackageSet] = None + + traffic_packages: Optional[PackageSet] = None + conversion_rate_rub_per_gb: Optional[float] = None + + @model_validator(mode="after") + def validate_tariff(self) -> "Tariff": + if not self.key.strip(): + raise ValueError("tariff key must not be empty") + self.key = self.key.strip() + self.squad_uuids = [uuid.strip() for uuid in self.squad_uuids if uuid.strip()] + + if self.billing_model == "period": + if self.monthly_gb is None or self.monthly_gb < 0: + raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0") + if not self.enabled_periods: + raise ValueError(f"period tariff {self.key}: enabled_periods is required") + for months in self.enabled_periods: + if months <= 0: + raise ValueError(f"period tariff {self.key}: enabled periods must be positive") + rub_price = self.prices_rub.get(str(months), 0) or 0 + stars_price = self.prices_stars.get(str(months), 0) or 0 + if rub_price <= 0 and stars_price <= 0: + raise ValueError( + f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" + ) + return self + + if not self.traffic_packages or not self.traffic_packages.has_any(): + raise ValueError(f"traffic tariff {self.key}: traffic_packages is required") + if self.conversion_rate_rub_per_gb is not None and self.conversion_rate_rub_per_gb <= 0: + raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0") + if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None: + raise ValueError( + f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" + ) + return self + + def name(self, lang: str, fallback: str = "ru") -> str: + return self.names.get(lang) or self.names.get(fallback) or self.key + + def description(self, lang: str, fallback: str = "ru") -> str: + return self.descriptions.get(lang) or self.descriptions.get(fallback) or "" + + @property + def monthly_bytes(self) -> int: + if self.monthly_gb is None or self.monthly_gb <= 0: + return 0 + return int(float(self.monthly_gb) * (1024**3)) + + def period_price(self, months: int, currency: Currency = "rub") -> Optional[float]: + source = self.prices_rub if currency == "rub" else self.prices_stars + value = source.get(str(months)) + return float(value) if value is not None else None + + def min_period_price_rub(self) -> Optional[float]: + prices = [ + float(self.prices_rub[str(months)]) + for months in self.enabled_periods + if self.prices_rub.get(str(months), 0) and self.prices_rub.get(str(months), 0) > 0 + ] + return min(prices) if prices else None + + def min_traffic_package_rub(self) -> Optional[TrafficPackage]: + packages = self.traffic_packages.rub if self.traffic_packages else [] + return min(packages, key=lambda pkg: pkg.price) if packages else None + + def rub_per_gb_for_conversion(self) -> float: + if self.conversion_rate_rub_per_gb: + return float(self.conversion_rate_rub_per_gb) + packages = self.traffic_packages.rub if self.traffic_packages else [] + return min(float(pkg.price) / float(pkg.gb) for pkg in packages) + + +class TariffsConfig(BaseModel): + default_tariff: str + topup_packages_default: Optional[PackageSet] = None + tariffs: List[Tariff] + + @model_validator(mode="after") + def validate_config(self) -> "TariffsConfig": + keys = [tariff.key for tariff in self.tariffs] + if len(keys) != len(set(keys)): + raise ValueError("tariff keys must be unique") + active = [tariff for tariff in self.tariffs if tariff.enabled] + if not active: + raise ValueError("at least one enabled tariff is required") + active_keys = {tariff.key for tariff in active} + if self.default_tariff not in active_keys: + raise ValueError("default_tariff must reference an enabled tariff") + return self + + @property + def enabled_tariffs(self) -> List[Tariff]: + return [tariff for tariff in self.tariffs if tariff.enabled] + + def get(self, key: str) -> Optional[Tariff]: + return next((tariff for tariff in self.tariffs if tariff.key == key), None) + + def require(self, key: str) -> Tariff: + tariff = self.get(key) + if not tariff or not tariff.enabled: + raise KeyError(f"Unknown or disabled tariff: {key}") + return tariff + + @property + def default(self) -> Tariff: + return self.require(self.default_tariff) + + def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]: + if tariff.billing_model == "traffic": + return tariff.traffic_packages + return tariff.topup_packages if tariff.topup_packages is not None else self.topup_packages_default + + +def load_tariffs_config(path: str | Path) -> Optional[TariffsConfig]: + config_path = Path(path) + if not config_path.exists(): + return None + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + return TariffsConfig.model_validate(data) + except (OSError, json.JSONDecodeError, ValidationError, ValueError) as exc: + logging.critical("Failed to load tariffs config from %s: %s", config_path, exc) + raise diff --git a/db/dal/tariff_dal.py b/db/dal/tariff_dal.py new file mode 100644 index 0000000..4729b87 --- /dev/null +++ b/db/dal/tariff_dal.py @@ -0,0 +1,97 @@ +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import TariffChange, TrafficTopup, TrafficWarning + + +async def create_traffic_topup( + session: AsyncSession, + *, + subscription_id: int, + payment_id: Optional[int], + purchased_bytes: int, + kind: str, +) -> TrafficTopup: + record = TrafficTopup( + subscription_id=subscription_id, + payment_id=payment_id, + purchased_bytes=purchased_bytes, + kind=kind, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record + + +async def create_tariff_change( + session: AsyncSession, + change_data: Dict[str, Any], +) -> TariffChange: + record = TariffChange(**change_data) + session.add(record) + await session.flush() + await session.refresh(record) + return record + + +async def get_warning( + session: AsyncSession, + *, + subscription_id: int, + period_start_at, + level: int, + traffic_limit_bytes: Optional[int] = None, +) -> Optional[TrafficWarning]: + conditions = [ + TrafficWarning.subscription_id == subscription_id, + TrafficWarning.level == level, + ] + if period_start_at is None: + conditions.append(TrafficWarning.period_start_at.is_(None)) + if traffic_limit_bytes is not None: + conditions.append(TrafficWarning.traffic_limit_bytes == traffic_limit_bytes) + else: + conditions.append(TrafficWarning.period_start_at == period_start_at) + result = await session.execute(select(TrafficWarning).where(and_(*conditions)).limit(1)) + return result.scalar_one_or_none() + + +async def create_warning( + session: AsyncSession, + *, + subscription_id: int, + period_start_at, + level: int, + traffic_limit_bytes: Optional[int], +) -> TrafficWarning: + record = TrafficWarning( + subscription_id=subscription_id, + period_start_at=period_start_at, + level=level, + traffic_limit_bytes=traffic_limit_bytes, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record + + +async def clear_period_warnings(session: AsyncSession, subscription_id: int) -> int: + result = await session.execute( + delete(TrafficWarning).where(TrafficWarning.subscription_id == subscription_id) + ) + return result.rowcount or 0 + + +async def get_tariff_changes_for_subscription( + session: AsyncSession, subscription_id: int +) -> List[TariffChange]: + result = await session.execute( + select(TariffChange) + .where(TariffChange.subscription_id == subscription_id) + .order_by(TariffChange.created_at.desc()) + ) + return list(result.scalars().all()) diff --git a/db/database_setup.py b/db/database_setup.py index 30fc731..b1da42c 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -72,6 +72,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): async with session_factory() as session: from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status + from sqlalchemy import text try: current_status = await get_panel_sync_status(session) if current_status is None: @@ -87,3 +88,56 @@ async def init_db(settings: Settings, session_factory: sessionmaker): logging.error( f"Failed to initialize PanelSyncStatus: {e_sync_init}", exc_info=True) + + if settings.tariffs_config: + try: + default_tariff = settings.tariffs_config.default + default_price = default_tariff.period_price(1, "rub") or default_tariff.min_period_price_rub() + await session.execute( + text( + """ + UPDATE subscriptions AS s + SET + tariff_key = COALESCE(s.tariff_key, :tariff_key), + tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline), + topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0), + period_start_at = COALESCE( + s.period_start_at, + ( + SELECT p.created_at + FROM payments p + WHERE p.user_id = s.user_id + AND p.status = 'succeeded' + ORDER BY p.created_at DESC + LIMIT 1 + ), + s.start_date, + NOW() + ), + effective_monthly_price_rub = COALESCE( + s.effective_monthly_price_rub, + ( + SELECT p.amount / GREATEST(COALESCE(p.subscription_duration_months, 1), 1) + FROM payments p + WHERE p.user_id = s.user_id + AND p.status = 'succeeded' + AND COALESCE(p.subscription_duration_months, 0) > 0 + ORDER BY p.created_at DESC + LIMIT 1 + ), + :default_price + ) + WHERE s.is_active = TRUE + AND s.tariff_key IS NULL + """ + ), + { + "tariff_key": default_tariff.key, + "baseline": default_tariff.monthly_bytes, + "default_price": default_price, + }, + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to backfill existing subscriptions for tariffs config.") diff --git a/db/migrator.py b/db/migrator.py index 84d914b..2a4e58f 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -303,6 +303,100 @@ def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None: ) +def _migration_0011_add_tariffs_schema(connection: Connection) -> None: + inspector = inspect(connection) + + sub_columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + sub_statements: List[str] = [] + if "tariff_key" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tariff_key VARCHAR") + if "tier_baseline_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN tier_baseline_bytes BIGINT") + if "topup_balance_bytes" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN topup_balance_bytes BIGINT NOT NULL DEFAULT 0") + if "period_start_at" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN period_start_at TIMESTAMPTZ") + if "is_throttled" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN is_throttled BOOLEAN NOT NULL DEFAULT FALSE") + if "effective_monthly_price_rub" not in sub_columns: + sub_statements.append("ALTER TABLE subscriptions ADD COLUMN effective_monthly_price_rub NUMERIC") + for stmt in sub_statements: + connection.execute(text(stmt)) + + payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} + payment_statements: List[str] = [] + if "sale_mode" not in payment_columns: + payment_statements.append("ALTER TABLE payments ADD COLUMN sale_mode VARCHAR") + if "tariff_key" not in payment_columns: + payment_statements.append("ALTER TABLE payments ADD COLUMN tariff_key VARCHAR") + if "purchased_gb" not in payment_columns: + payment_statements.append("ALTER TABLE payments ADD COLUMN purchased_gb DOUBLE PRECISION") + for stmt in payment_statements: + connection.execute(text(stmt)) + + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS traffic_topups ( + topup_id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), + payment_id INTEGER NULL REFERENCES payments(payment_id), + purchased_bytes BIGINT NOT NULL, + kind VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS traffic_warnings ( + warning_id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), + period_start_at TIMESTAMPTZ NULL, + level INTEGER NOT NULL, + traffic_limit_bytes BIGINT NULL, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS tariff_changes ( + change_id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), + from_tariff_key VARCHAR NULL, + to_tariff_key VARCHAR NOT NULL, + mode VARCHAR NOT NULL, + payment_id INTEGER NULL REFERENCES payments(payment_id), + days_before INTEGER NULL, + days_after INTEGER NULL, + converted_bytes BIGINT NULL, + eff_price_before NUMERIC NULL, + eff_price_after NUMERIC NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + for stmt in [ + "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", + "CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)", + "CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)", + "CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)", + "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", + "CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)", + "CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)", + "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", + "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", + ]: + connection.execute(text(stmt)) + + def _migration_0009_add_composite_indexes(connection: Connection) -> None: connection.execute( text( @@ -381,6 +475,11 @@ MIGRATIONS: List[Migration] = [ description="Store hashed magic-link tokens for email login deeplinks", upgrade=_migration_0010_add_email_magic_token_hash, ), + Migration( + id="0011_add_tariffs_schema", + description="Add tariff catalog columns and traffic accounting tables", + upgrade=_migration_0011_add_tariffs_schema, + ), ] diff --git a/db/models.py b/db/models.py index b50f191..b6c6bfe 100644 --- a/db/models.py +++ b/db/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger, Index, Numeric from sqlalchemy.orm import relationship, DeclarativeBase from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.sql import func @@ -87,6 +87,12 @@ class Subscription(Base): provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) auto_renew_enabled = Column(Boolean, default=True, index=True) + tariff_key = Column(String, nullable=True, index=True) + tier_baseline_bytes = Column(BigInteger, nullable=True) + topup_balance_bytes = Column(BigInteger, nullable=False, default=0) + period_start_at = Column(DateTime(timezone=True), nullable=True) + is_throttled = Column(Boolean, nullable=False, default=False, index=True) + effective_monthly_price_rub = Column(Numeric, nullable=True) user = relationship("User", back_populates="subscriptions") @@ -158,6 +164,9 @@ class Payment(Base): status = Column(String, nullable=False, index=True) description = Column(String, nullable=True) subscription_duration_months = Column(Integer, nullable=True) + sale_mode = Column(String, nullable=True, index=True) + tariff_key = Column(String, nullable=True, index=True) + purchased_gb = Column(Float, nullable=True) promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True) @@ -171,6 +180,56 @@ class Payment(Base): back_populates="payments_where_used") +class TrafficTopup(Base): + __tablename__ = "traffic_topups" + + topup_id = Column(Integer, primary_key=True, autoincrement=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True) + payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True) + purchased_bytes = Column(BigInteger, nullable=False) + kind = Column(String, nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + subscription = relationship("Subscription") + payment = relationship("Payment") + + +class TrafficWarning(Base): + __tablename__ = "traffic_warnings" + __table_args__ = ( + UniqueConstraint("subscription_id", "period_start_at", "level", name="uq_traffic_warning_period_level"), + ) + + warning_id = Column(Integer, primary_key=True, autoincrement=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True) + period_start_at = Column(DateTime(timezone=True), nullable=True) + level = Column(Integer, nullable=False) + traffic_limit_bytes = Column(BigInteger, nullable=True) + sent_at = Column(DateTime(timezone=True), server_default=func.now()) + + subscription = relationship("Subscription") + + +class TariffChange(Base): + __tablename__ = "tariff_changes" + + change_id = Column(Integer, primary_key=True, autoincrement=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True) + from_tariff_key = Column(String, nullable=True) + to_tariff_key = Column(String, nullable=False) + mode = Column(String, nullable=False, index=True) + payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True) + days_before = Column(Integer, nullable=True) + days_after = Column(Integer, nullable=True) + converted_bytes = Column(BigInteger, nullable=True) + eff_price_before = Column(Numeric, nullable=True) + eff_price_after = Column(Numeric, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + subscription = relationship("Subscription") + payment = relationship("Payment") + + class UserBilling(Base): __tablename__ = "user_billing" diff --git a/tests/test_settings.py b/tests/test_settings.py index ea4d01d..9ef3068 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,5 @@ import unittest +import json from pydantic import ValidationError @@ -26,3 +27,56 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.WEBAPP_SESSION_SECRET) self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) + + def test_tariffs_config_missing_uses_legacy_fallback(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + TRAFFIC_PACKAGES="10:199", + ) + + self.assertIsNone(settings.tariffs_config) + self.assertTrue(settings.traffic_sale_mode) + + def test_existing_tariffs_config_disables_legacy_traffic_mode(self): + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"ru": "Стандарт"}, + "descriptions": {}, + "squad_uuids": ["uuid"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "enabled": True, + } + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + TRAFFIC_PACKAGES="10:199", + ) + + self.assertIsNotNone(settings.tariffs_config) + self.assertFalse(settings.traffic_sale_mode) diff --git a/tests/test_tariffs_config.py b/tests/test_tariffs_config.py new file mode 100644 index 0000000..6ddf164 --- /dev/null +++ b/tests/test_tariffs_config.py @@ -0,0 +1,90 @@ +import json +import unittest + + +from config.tariffs_config import TariffsConfig, load_tariffs_config + + +def _valid_config(): + return { + "default_tariff": "standard", + "topup_packages_default": { + "rub": [{"gb": 10, "price": 99}], + "stars": [{"gb": 10, "price": 2500}], + }, + "tariffs": [ + { + "key": "standard", + "names": {"ru": "Стандарт", "en": "Standard"}, + "descriptions": {"ru": "Base"}, + "squad_uuids": ["uuid-1"], + "billing_model": "period", + "monthly_gb": 500, + "prices_rub": {"1": 150}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "enabled": True, + }, + { + "key": "traffic", + "names": {"ru": "Гигабайты"}, + "descriptions": {}, + "squad_uuids": ["uuid-1"], + "billing_model": "traffic", + "traffic_packages": {"rub": [{"gb": 10, "price": 199}], "stars": []}, + "enabled": True, + }, + ], + } + + +class TariffsConfigTests(unittest.TestCase): + def test_valid_tariffs_config_loads(self): + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text(json.dumps(_valid_config()), encoding="utf-8") + + config = load_tariffs_config(path) + + self.assertIsNotNone(config) + self.assertEqual(config.default.key, "standard") + self.assertEqual(config.require("traffic").rub_per_gb_for_conversion(), 19.9) + + def test_missing_config_returns_none(self): + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmpdir: + self.assertIsNone(load_tariffs_config(Path(tmpdir) / "missing.json")) + + def test_duplicate_keys_rejected(self): + data = _valid_config() + data["tariffs"][1]["key"] = "standard" + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) + + def test_default_must_be_enabled(self): + data = _valid_config() + data["default_tariff"] = "missing" + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) + + def test_period_price_required_for_enabled_period(self): + data = _valid_config() + data["tariffs"][0]["prices_rub"] = {"1": 0} + data["tariffs"][0]["prices_stars"] = {"1": 0} + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) + + def test_traffic_without_rub_needs_conversion_rate(self): + data = _valid_config() + data["tariffs"][1]["traffic_packages"] = {"rub": [], "stars": [{"gb": 10, "price": 2500}]} + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data)