fix: bind HWID top-ups to subscription periods

This commit is contained in:
3252a8
2026-05-25 19:33:50 +03:00
parent 59fa301344
commit c68cb97964
49 changed files with 2027 additions and 119 deletions
+129 -2
View File
@@ -1,9 +1,11 @@
import inspect
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, select
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning
from db.models import HwidDevicePurchase, Payment, TariffChange, TrafficTopup, TrafficWarning
async def create_traffic_topup(
@@ -52,11 +54,15 @@ async def create_hwid_device_purchase(
subscription_id: int,
payment_id: Optional[int],
purchased_devices: int,
valid_from: Optional[datetime] = None,
valid_until: Optional[datetime] = None,
) -> HwidDevicePurchase:
record = HwidDevicePurchase(
subscription_id=subscription_id,
payment_id=payment_id,
purchased_devices=purchased_devices,
valid_from=valid_from or datetime.now(timezone.utc),
valid_until=valid_until,
)
session.add(record)
await session.flush()
@@ -64,6 +70,127 @@ async def create_hwid_device_purchase(
return record
def _hwid_active_conditions(subscription_id: int, at: datetime) -> List[Any]:
return [
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_from.is_(None), HwidDevicePurchase.valid_from <= at),
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
]
async def _resolve_result_value(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
async def sum_active_hwid_devices(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> int:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0)).where(
and_(*_hwid_active_conditions(subscription_id, at))
)
)
return int(await _resolve_result_value(result.scalar()) or 0)
async def get_hwid_device_entitlement_summary(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> Dict[str, Any]:
at = at or datetime.now(timezone.utc)
active_result = await session.execute(
select(
func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0),
func.max(HwidDevicePurchase.valid_until),
).where(and_(*_hwid_active_conditions(subscription_id, at)))
)
active_devices, active_until = await _resolve_result_value(active_result.one())
future_result = await session.execute(
select(func.min(HwidDevicePurchase.valid_from)).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_from > at,
)
)
)
return {
"active_devices": int(active_devices or 0),
"active_until": active_until,
"next_valid_from": await _resolve_result_value(future_result.scalar_one_or_none()),
}
async def get_hwid_device_value_entries(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(
HwidDevicePurchase.purchase_id,
HwidDevicePurchase.purchased_devices,
HwidDevicePurchase.valid_from,
HwidDevicePurchase.valid_until,
HwidDevicePurchase.created_at,
Payment.amount,
Payment.currency,
)
.outerjoin(Payment, Payment.payment_id == HwidDevicePurchase.payment_id)
.where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
)
)
)
entries = []
rows = await _resolve_result_value(result.all())
for row in rows:
entries.append(
{
"purchase_id": row[0],
"purchased_devices": row[1],
"valid_from": row[2],
"valid_until": row[3],
"created_at": row[4],
"amount": row[5],
"currency": row[6],
}
)
return entries
async def expire_hwid_device_purchases(
session: AsyncSession,
*,
purchase_ids: List[int],
at: Optional[datetime] = None,
) -> int:
ids = [int(item) for item in purchase_ids if item is not None]
if not ids:
return 0
at = at or datetime.now(timezone.utc)
result = await session.execute(
update(HwidDevicePurchase)
.where(HwidDevicePurchase.purchase_id.in_(ids))
.values(valid_until=at)
)
return result.rowcount or 0
async def create_tariff_change(
session: AsyncSession,
change_data: Dict[str, Any],
+3 -2
View File
@@ -1,5 +1,6 @@
import logging
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -10,6 +11,7 @@ from db.models import Base
from .migrator import run_database_migrations
async_engine = None
DB_INIT_ADVISORY_LOCK_ID = 817512404897421337
def redacted_database_url(database_url: str) -> str:
@@ -71,6 +73,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
)
async with async_engine.begin() as conn:
await conn.execute(text(f"SELECT pg_advisory_xact_lock({DB_INIT_ADVISORY_LOCK_ID})"))
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.")
@@ -83,8 +86,6 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
logging.warning(f"Failed to load setting overrides on startup: {e_overrides}")
async with session_factory() as session:
from sqlalchemy import text
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
try:
+107
View File
@@ -919,6 +919,103 @@ def _migration_0028_add_locale_overrides(connection: Connection) -> None:
)
def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) -> None:
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names:
return
columns: Set[str] = {
col["name"] for col in inspector.get_columns("hwid_device_purchases")
}
if "valid_from" not in columns:
connection.execute(
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ")
)
if "valid_until" not in columns:
connection.execute(
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_until TIMESTAMPTZ")
)
connection.execute(
text(
"""
UPDATE hwid_device_purchases hp
SET
valid_from = COALESCE(hp.valid_from, hp.created_at, s.start_date, NOW()),
valid_until = COALESCE(hp.valid_until, s.end_date)
FROM subscriptions s
WHERE hp.subscription_id = s.subscription_id
AND (hp.valid_from IS NULL OR hp.valid_until IS NULL)
"""
)
)
connection.execute(
text(
"""
INSERT INTO hwid_device_purchases (
subscription_id,
payment_id,
purchased_devices,
valid_from,
valid_until
)
SELECT
s.subscription_id,
NULL,
s.extra_hwid_devices,
COALESCE(s.start_date, NOW()),
s.end_date
FROM subscriptions s
WHERE COALESCE(s.extra_hwid_devices, 0) > 0
AND s.end_date IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM hwid_device_purchases hp
WHERE hp.subscription_id = s.subscription_id
)
"""
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_window "
"ON hwid_device_purchases (subscription_id, valid_from, valid_until)"
)
)
def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "payments" in table_names:
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
payment_additions = {
"hwid_valid_from": "TIMESTAMPTZ",
"hwid_valid_until": "TIMESTAMPTZ",
"hwid_pricing_period_months": "INTEGER",
"hwid_proration_ratio": "DOUBLE PRECISION",
"hwid_full_price": "DOUBLE PRECISION",
}
for column, ddl_type in payment_additions.items():
if column not in payment_columns:
connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}"))
if "tariff_changes" in table_names:
change_columns: Set[str] = {
col["name"] for col in inspector.get_columns("tariff_changes")
}
change_additions = {
"converted_hwid_value_rub": "NUMERIC",
"converted_hwid_days": "INTEGER",
}
for column, ddl_type in change_additions.items():
if column not in change_columns:
connection.execute(
text(f"ALTER TABLE tariff_changes ADD COLUMN {column} {ddl_type}")
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1071,6 +1168,16 @@ MIGRATIONS: List[Migration] = [
description="Persist runtime overrides for localization strings",
upgrade=_migration_0028_add_locale_overrides,
),
Migration(
id="0029_add_hwid_device_purchase_validity",
description="Track validity windows for HWID device top-ups",
upgrade=_migration_0029_add_hwid_device_purchase_validity,
),
Migration(
id="0030_add_hwid_pricing_metadata",
description="Persist quoted HWID top-up pricing windows and conversion audit",
upgrade=_migration_0030_add_hwid_pricing_metadata,
),
]
+17
View File
@@ -203,6 +203,11 @@ class Payment(Base):
tariff_key = Column(String, nullable=True, index=True)
purchased_gb = Column(Float, nullable=True)
purchased_hwid_devices = Column(Integer, nullable=True)
hwid_valid_from = Column(DateTime(timezone=True), nullable=True)
hwid_valid_until = Column(DateTime(timezone=True), nullable=True)
hwid_pricing_period_months = Column(Integer, nullable=True)
hwid_proration_ratio = Column(Float, nullable=True)
hwid_full_price = Column(Float, nullable=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
@@ -229,6 +234,14 @@ class TrafficTopup(Base):
class HwidDevicePurchase(Base):
__tablename__ = "hwid_device_purchases"
__table_args__ = (
Index(
"ix_hwid_device_purchases_subscription_window",
"subscription_id",
"valid_from",
"valid_until",
),
)
purchase_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
@@ -236,6 +249,8 @@ class HwidDevicePurchase(Base):
)
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True)
purchased_devices = Column(Integer, nullable=False)
valid_from = Column(DateTime(timezone=True), nullable=True)
valid_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
@@ -276,6 +291,8 @@ class TariffChange(Base):
days_before = Column(Integer, nullable=True)
days_after = Column(Integer, nullable=True)
converted_bytes = Column(BigInteger, nullable=True)
converted_hwid_value_rub = Column(Numeric, nullable=True)
converted_hwid_days = Column(Integer, 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())