Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 9m36s
CI / fmt + clippy + test (push) Failing after 24m37s

- modules::geoip — DB-IP Lite (.mmdb, без регистрации/ключа, в отличие от
  MaxMind) для страны по IP, X4BNet lists_vpn для грубой VPN/датацентр-
  эвристики. Обе базы скачиваются в рантайме, обновление — фоновая
  задача в tasks.rs (раз в сутки по умолчанию, GEOIP_REFRESH_INTERVAL).
- GET /api/v1/billing/plans (public_router, без auth_guard) — цены в
  валюте, определённой по IP посетителя (X-Real-IP/X-Forwarded-For,
  см. nginx-конфиги). VPN/датацентр IP -> всегда базовый USD, чтобы
  обход региона через любой публичный VPN не обесценивал скидки.
- Бот: Bot API не отдаёт IP пользователя вообще — валюта определяется по
  Telegram language_code. Показ цены — в валюте региона, САМА оплата
  через CryptoBot остаётся в USD/USDT (провайдер биллит 1:1 к USD-цене
  тарифа, менять валюту списания нельзя, не меняя сам провайдер).
This commit is contained in:
2026-07-11 00:55:03 +07:00
parent a89721fbee
commit d91aef012e
15 changed files with 557 additions and 13 deletions
+24
View File
@@ -110,6 +110,30 @@ impl BillingService {
.map_err(AppError::Database)
}
/// Тарифы в конкретной валюте — для `GET /plans` (см.
/// `billing::controller::list_plans_api`) и мультивалютного бота
/// (`bot.rs`). Если для этой валюты в `plan_prices` нет ни одной активной
/// строки (валюта определилась, но регион не заведён — маловероятно,
/// т.к. валюта берётся из той же карты, что и миграция, но на всякий
/// случай) — тихо фолбэкается на USD, а не отдаёт пустой список.
pub async fn list_plans_in_currency(
&self,
currency: &str,
) -> Result<(String, Vec<(String, i64)>), AppError> {
if currency != "USD" {
let regional = self
.repo
.list_active_plans_by_currency(currency)
.await
.map_err(AppError::Database)?;
if !regional.is_empty() {
return Ok((currency.to_string(), regional));
}
}
let usd = self.list_plans_usd().await?;
Ok(("USD".to_string(), usd))
}
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {