d91aef012e
- 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-цене тарифа, менять валюту списания нельзя, не меняя сам провайдер).
154 lines
5.1 KiB
Rust
154 lines
5.1 KiB
Rust
use axum::{
|
|
extract::{ConnectInfo, Extension, State},
|
|
http::HeaderMap,
|
|
middleware,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
api::ApiState,
|
|
db::models::User,
|
|
error::AppError,
|
|
modules::{auth::guard::auth_guard, geoip::currency::currency_for_country},
|
|
};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct InitiatePaymentPayload {
|
|
pub plan: String,
|
|
pub provider: String,
|
|
pub currency: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ConfirmPaymentPayload {
|
|
pub invoice_id: Uuid,
|
|
pub external_tx_id: String,
|
|
}
|
|
|
|
/// Без auth_guard — цены должны быть видны анонимному посетителю сайта
|
|
/// (см. netrunner-landing/components/sections/Pricing.tsx), не только
|
|
/// залогиненным.
|
|
pub fn public_router() -> Router<ApiState> {
|
|
Router::new().route("/plans", get(list_plans_api))
|
|
}
|
|
|
|
pub fn router(state: ApiState) -> Router<ApiState> {
|
|
Router::new()
|
|
.route("/pay/initiate", post(initiate_payment_api))
|
|
.route("/pay/confirm", post(confirm_payment_api))
|
|
.route("/trial", post(activate_trial_api))
|
|
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
|
}
|
|
|
|
/// IP реального посетителя — из `X-Real-IP` (его ставит nginx на проде, см.
|
|
/// netrunner-data/nginx/*, netrunner-backend/nginx/*, восстановлен из
|
|
/// `CF-Connecting-IP` через `set_real_ip_from`/`real_ip_header`), иначе
|
|
/// `X-Forwarded-For` (первый адрес в цепочке), иначе — реальный TCP-пир
|
|
/// (для локальной разработки без nginx впереди).
|
|
fn extract_client_ip(headers: &HeaderMap, connect_info: &SocketAddr) -> IpAddr {
|
|
if let Some(real_ip) = headers
|
|
.get("x-real-ip")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.trim().parse().ok())
|
|
{
|
|
return real_ip;
|
|
}
|
|
if let Some(forwarded) = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.split(',').next())
|
|
.and_then(|s| s.trim().parse().ok())
|
|
{
|
|
return forwarded;
|
|
}
|
|
connect_info.ip()
|
|
}
|
|
|
|
/// Тарифы в валюте, определённой по региону посетителя. Если IP похож на
|
|
/// VPN/датацентр (см. `geoip_service.is_vpn`) — намеренно НЕ используем
|
|
/// региональную цену, отдаём базовую USD (иначе покупка "из региона" через
|
|
/// любой публичный VPN обесценивала бы всю схему региональных скидок).
|
|
async fn list_plans_api(
|
|
State(state): State<ApiState>,
|
|
headers: HeaderMap,
|
|
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let ip = extract_client_ip(&headers, &connect_info);
|
|
|
|
let is_vpn = state.geoip_service.is_vpn(ip).await;
|
|
let country = state.geoip_service.country_for_ip(ip).await;
|
|
let currency = if is_vpn {
|
|
"USD"
|
|
} else {
|
|
country
|
|
.as_deref()
|
|
.and_then(currency_for_country)
|
|
.unwrap_or("USD")
|
|
};
|
|
|
|
let (resolved_currency, plans) = state
|
|
.billing_service
|
|
.list_plans_in_currency(currency)
|
|
.await?;
|
|
|
|
Ok(Json(json!({
|
|
"currency": resolved_currency,
|
|
"detected_country": country,
|
|
"vpn_detected": is_vpn,
|
|
"plans": plans.into_iter().map(|(name, amount)| json!({
|
|
"plan": name,
|
|
"amount": amount,
|
|
})).collect::<Vec<_>>(),
|
|
})))
|
|
}
|
|
|
|
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
|
|
async fn initiate_payment_api(
|
|
Extension(user): Extension<User>,
|
|
State(state): State<ApiState>,
|
|
Json(payload): Json<InitiatePaymentPayload>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let checkout_data = state
|
|
.billing_service
|
|
.initiate_payment(&user, &payload.plan, &payload.provider, &payload.currency)
|
|
.await?;
|
|
|
|
Ok(Json(json!({
|
|
"status": "success",
|
|
"checkout_data": checkout_data
|
|
})))
|
|
}
|
|
|
|
/// Шаг 2: Подтверждаем транзакцию после того как юзер её оплатил
|
|
async fn confirm_payment_api(
|
|
Extension(user): Extension<User>,
|
|
State(state): State<ApiState>,
|
|
Json(payload): Json<ConfirmPaymentPayload>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
state
|
|
.billing_service
|
|
.confirm_payment(user.id, payload.invoice_id, &payload.external_tx_id)
|
|
.await?;
|
|
|
|
Ok(Json(json!({
|
|
"status": "success",
|
|
"message": "Payment verified. Uplink activated."
|
|
})))
|
|
}
|
|
|
|
async fn activate_trial_api(
|
|
Extension(user): Extension<User>,
|
|
State(state): State<ApiState>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
state.billing_service.activate_trial(&user).await?;
|
|
Ok(Json(json!({
|
|
"status": "success",
|
|
"message": "Trial Uplink Activated"
|
|
})))
|
|
}
|