feat: add tariff config and database schema
This commit is contained in:
@@ -142,6 +142,7 @@ STARS_PRICE_12_MONTHS=0
|
||||
# Traffic Packages (enables traffic sale mode when set)
|
||||
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", 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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+60
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user