feat: add multicurrency tariff payments
This commit is contained in:
@@ -1,14 +1,53 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
from pydantic import BaseModel, Field, RootModel, ValidationError, model_validator
|
||||
|
||||
Currency = Literal["rub", "stars"]
|
||||
DEFAULT_TARIFF_CURRENCY = "rub"
|
||||
STARS_TARIFF_CURRENCY = "stars"
|
||||
|
||||
Currency = str
|
||||
BillingModel = Literal["period", "traffic"]
|
||||
|
||||
|
||||
def normalize_currency_key(value: Any, default: str = DEFAULT_TARIFF_CURRENCY) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return default
|
||||
aliases = {
|
||||
"rur": "rub",
|
||||
"xtr": STARS_TARIFF_CURRENCY,
|
||||
"star": STARS_TARIFF_CURRENCY,
|
||||
"stars": STARS_TARIFF_CURRENCY,
|
||||
}
|
||||
normalized = aliases.get(text, text)
|
||||
cleaned = "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-"}).strip("_-")
|
||||
return cleaned or default
|
||||
|
||||
|
||||
def payment_currency_code(currency: Any, default: str = "RUB") -> str:
|
||||
key = normalize_currency_key(currency, default=normalize_currency_key(default))
|
||||
if key == STARS_TARIFF_CURRENCY:
|
||||
return "XTR"
|
||||
return key.upper()
|
||||
|
||||
|
||||
def default_currency_key_for_settings(settings: Any) -> str:
|
||||
try:
|
||||
config = getattr(settings, "tariffs_config", None)
|
||||
except Exception:
|
||||
config = None
|
||||
if config is not None and getattr(config, "default_currency", None):
|
||||
return normalize_currency_key(config.default_currency)
|
||||
return normalize_currency_key(getattr(settings, "DEFAULT_CURRENCY_SYMBOL", None))
|
||||
|
||||
|
||||
def default_payment_currency_code_for_settings(settings: Any) -> str:
|
||||
return payment_currency_code(default_currency_key_for_settings(settings))
|
||||
|
||||
|
||||
class TrafficPackage(BaseModel):
|
||||
gb: float
|
||||
price: float
|
||||
@@ -61,26 +100,74 @@ class HwidDevicePackage(BaseModel):
|
||||
return float(self.price) * months_int
|
||||
|
||||
|
||||
class PackageSet(BaseModel):
|
||||
rub: List[TrafficPackage] = Field(default_factory=list)
|
||||
stars: List[TrafficPackage] = Field(default_factory=list)
|
||||
class PackageSet(RootModel[Dict[str, List[TrafficPackage]]]):
|
||||
root: Dict[str, List[TrafficPackage]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_input(cls, data: Any) -> Any:
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
normalized: Dict[str, Any] = {}
|
||||
for currency, packages in data.items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError("package currency must not be empty")
|
||||
normalized[key] = packages or []
|
||||
return normalized
|
||||
|
||||
def for_currency(self, currency: Currency) -> List[TrafficPackage]:
|
||||
return list(getattr(self, currency) or [])
|
||||
return list(self.root.get(normalize_currency_key(currency), []) or [])
|
||||
|
||||
@property
|
||||
def rub(self) -> List[TrafficPackage]:
|
||||
return self.for_currency("rub")
|
||||
|
||||
@property
|
||||
def stars(self) -> List[TrafficPackage]:
|
||||
return self.for_currency("stars")
|
||||
|
||||
@property
|
||||
def non_stars_currencies(self) -> List[str]:
|
||||
return [currency for currency, packages in self.root.items() if currency != "stars" and packages]
|
||||
|
||||
def has_any(self) -> bool:
|
||||
return bool(self.rub or self.stars)
|
||||
return any(bool(packages) for packages in self.root.values())
|
||||
|
||||
|
||||
class HwidDevicePackageSet(BaseModel):
|
||||
rub: List[HwidDevicePackage] = Field(default_factory=list)
|
||||
stars: List[HwidDevicePackage] = Field(default_factory=list)
|
||||
class HwidDevicePackageSet(RootModel[Dict[str, List[HwidDevicePackage]]]):
|
||||
root: Dict[str, List[HwidDevicePackage]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_input(cls, data: Any) -> Any:
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
normalized: Dict[str, Any] = {}
|
||||
for currency, packages in data.items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError("device package currency must not be empty")
|
||||
normalized[key] = packages or []
|
||||
return normalized
|
||||
|
||||
def for_currency(self, currency: Currency) -> List[HwidDevicePackage]:
|
||||
return list(getattr(self, currency) or [])
|
||||
return list(self.root.get(normalize_currency_key(currency), []) or [])
|
||||
|
||||
@property
|
||||
def rub(self) -> List[HwidDevicePackage]:
|
||||
return self.for_currency("rub")
|
||||
|
||||
@property
|
||||
def stars(self) -> List[HwidDevicePackage]:
|
||||
return self.for_currency("stars")
|
||||
|
||||
def has_any(self) -> bool:
|
||||
return bool(self.rub or self.stars)
|
||||
return any(bool(packages) for packages in self.root.values())
|
||||
|
||||
|
||||
class Tariff(BaseModel):
|
||||
@@ -93,6 +180,7 @@ class Tariff(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
monthly_gb: Optional[float] = None
|
||||
prices: Dict[str, Dict[str, float]] = Field(default_factory=dict)
|
||||
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
||||
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
||||
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||
@@ -101,6 +189,7 @@ class Tariff(BaseModel):
|
||||
topup_packages: Optional[PackageSet] = None
|
||||
|
||||
traffic_packages: Optional[PackageSet] = None
|
||||
conversion_rate_per_gb: Optional[float] = None
|
||||
conversion_rate_rub_per_gb: Optional[float] = None
|
||||
hwid_device_limit: Optional[int] = None
|
||||
hwid_device_packages: Optional[HwidDevicePackageSet] = None
|
||||
@@ -128,6 +217,26 @@ class Tariff(BaseModel):
|
||||
if self.premium_monthly_gb and self.premium_monthly_gb > 0 and not self.premium_squad_uuids:
|
||||
raise ValueError(f"tariff {self.key}: premium_monthly_gb requires premium_squad_uuids")
|
||||
|
||||
self.prices = self._normalize_prices_by_currency(self.prices)
|
||||
self.prices_rub = self._normalize_period_price_map(self.prices_rub, "prices_rub")
|
||||
self.prices_stars = self._normalize_period_price_map(
|
||||
self.prices_stars,
|
||||
"prices_stars",
|
||||
)
|
||||
if self.prices_rub:
|
||||
self.prices["rub"] = dict(self.prices_rub)
|
||||
elif self.prices.get("rub"):
|
||||
self.prices_rub = dict(self.prices["rub"])
|
||||
if self.prices_stars:
|
||||
self.prices["stars"] = dict(self.prices_stars)
|
||||
elif self.prices.get("stars"):
|
||||
self.prices_stars = dict(self.prices["stars"])
|
||||
|
||||
if self.conversion_rate_per_gb is None and self.conversion_rate_rub_per_gb is not None:
|
||||
self.conversion_rate_per_gb = float(self.conversion_rate_rub_per_gb)
|
||||
if self.conversion_rate_per_gb is not None and self.conversion_rate_per_gb <= 0:
|
||||
raise ValueError(f"traffic tariff {self.key}: conversion_rate_per_gb must be > 0")
|
||||
|
||||
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")
|
||||
@@ -144,24 +253,54 @@ class Tariff(BaseModel):
|
||||
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:
|
||||
period_prices = [
|
||||
float(prices.get(str(months), 0) or 0) for prices in self.prices.values()
|
||||
]
|
||||
if not any(price > 0 for price in period_prices):
|
||||
raise ValueError(
|
||||
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
|
||||
f"period tariff {self.key}: period {months} needs a non-zero 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:
|
||||
if not self.traffic_packages.non_stars_currencies and self.conversion_rate_per_gb is None:
|
||||
raise ValueError(
|
||||
f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501
|
||||
f"traffic tariff {self.key}: conversion_rate_per_gb is required without fiat packages" # noqa: E501
|
||||
)
|
||||
return self
|
||||
|
||||
def _normalize_period_price_map(
|
||||
self,
|
||||
values: Dict[str, float],
|
||||
field_name: str,
|
||||
) -> Dict[str, float]:
|
||||
normalized: Dict[str, float] = {}
|
||||
for period, value in (values or {}).items():
|
||||
try:
|
||||
months = int(float(str(period).strip()))
|
||||
price = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry") from exc
|
||||
if months <= 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||
if price < 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} prices must be >= 0")
|
||||
normalized[str(months)] = price
|
||||
return normalized
|
||||
|
||||
def _normalize_prices_by_currency(
|
||||
self,
|
||||
values: Dict[str, Dict[str, float]],
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
normalized: Dict[str, Dict[str, float]] = {}
|
||||
for currency, price_map in (values or {}).items():
|
||||
key = normalize_currency_key(currency, default="")
|
||||
if not key:
|
||||
raise ValueError(f"tariff {self.key}: price currency must not be empty")
|
||||
normalized[key] = self._normalize_period_price_map(price_map or {}, f"prices.{key}")
|
||||
return normalized
|
||||
|
||||
def _normalize_referral_bonus_map(
|
||||
self, values: Dict[str, int], field_name: str
|
||||
) -> Dict[str, int]:
|
||||
@@ -196,7 +335,7 @@ class Tariff(BaseModel):
|
||||
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
|
||||
source = self.prices.get(normalize_currency_key(currency), {})
|
||||
value = source.get(str(months))
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@@ -208,24 +347,40 @@ class Tariff(BaseModel):
|
||||
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def min_period_price_rub(self) -> Optional[float]:
|
||||
def min_period_price(self, currency: Currency = "rub") -> Optional[float]:
|
||||
key = normalize_currency_key(currency)
|
||||
source = self.prices.get(key, {})
|
||||
prices = [
|
||||
float(self.prices_rub[str(months)])
|
||||
float(source[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
|
||||
if source.get(str(months), 0) and source.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 []
|
||||
def min_period_price_rub(self) -> Optional[float]:
|
||||
return self.min_period_price("rub")
|
||||
|
||||
def min_traffic_package(self, currency: Currency = "rub") -> Optional[TrafficPackage]:
|
||||
packages = self.traffic_packages.for_currency(currency) 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 []
|
||||
def min_traffic_package_rub(self) -> Optional[TrafficPackage]:
|
||||
return self.min_traffic_package("rub")
|
||||
|
||||
def currency_per_gb_for_conversion(self, currency: Currency = "rub") -> float:
|
||||
if self.conversion_rate_per_gb:
|
||||
return float(self.conversion_rate_per_gb)
|
||||
packages = self.traffic_packages.for_currency(currency) if self.traffic_packages else []
|
||||
if not packages and self.traffic_packages:
|
||||
for key in self.traffic_packages.non_stars_currencies:
|
||||
packages = self.traffic_packages.for_currency(key)
|
||||
if packages:
|
||||
break
|
||||
return min(float(pkg.price) / float(pkg.gb) for pkg in packages)
|
||||
|
||||
def rub_per_gb_for_conversion(self) -> float:
|
||||
return self.currency_per_gb_for_conversion("rub")
|
||||
|
||||
def has_hwid_device_packages(self) -> bool:
|
||||
return bool(self.hwid_device_packages and self.hwid_device_packages.has_any())
|
||||
|
||||
@@ -244,11 +399,15 @@ class Tariff(BaseModel):
|
||||
|
||||
class TariffsConfig(BaseModel):
|
||||
default_tariff: str
|
||||
default_currency: str = DEFAULT_TARIFF_CURRENCY
|
||||
topup_packages_default: Optional[PackageSet] = None
|
||||
tariffs: List[Tariff]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_config(self) -> "TariffsConfig":
|
||||
self.default_currency = normalize_currency_key(self.default_currency)
|
||||
if self.default_currency == STARS_TARIFF_CURRENCY:
|
||||
raise ValueError("default_currency must be a non-Stars payment currency")
|
||||
keys = [tariff.key for tariff in self.tariffs]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("tariff keys must be unique")
|
||||
@@ -277,6 +436,10 @@ class TariffsConfig(BaseModel):
|
||||
def default(self) -> Tariff:
|
||||
return self.require(self.default_tariff)
|
||||
|
||||
@property
|
||||
def default_payment_currency_code(self) -> str:
|
||||
return payment_currency_code(self.default_currency)
|
||||
|
||||
def topup_packages_for(self, tariff: Tariff) -> Optional[PackageSet]:
|
||||
if tariff.billing_model == "traffic":
|
||||
return tariff.traffic_packages
|
||||
|
||||
Reference in New Issue
Block a user