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-цене тарифа, менять валюту списания нельзя, не меняя сам провайдер).
831 lines
36 KiB
Rust
831 lines
36 KiB
Rust
use super::providers::{InvoiceData, PaymentProvider};
|
||
use crate::{
|
||
db::{
|
||
models::{Subscription, User},
|
||
repository::AppRepository,
|
||
},
|
||
error::AppError,
|
||
};
|
||
use redis::AsyncCommands;
|
||
use rust_decimal::prelude::FromPrimitive;
|
||
use std::{collections::HashMap, sync::Arc};
|
||
use uuid::Uuid;
|
||
|
||
/// Курс TON меняется медленно относительно времени жизни одного инвойса —
|
||
/// 60с кеша достаточно, чтобы не бить TonAPI на каждый `initiate_payment`,
|
||
/// но не настолько долго, чтобы курс успел заметно уйти от рынка.
|
||
const TON_RATE_CACHE_TTL_SEC: u64 = 60;
|
||
|
||
pub struct BillingService {
|
||
repo: Arc<dyn AppRepository>,
|
||
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
|
||
// Redis — только кеш, не источник истины: если он недоступен, просто
|
||
// ходим в TonAPI напрямую на каждый запрос (как было раньше).
|
||
redis: Option<redis::aio::ConnectionManager>,
|
||
}
|
||
|
||
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко.
|
||
// TODO: axum клонирует ApiState (а значит и BillingService) на каждый запрос —
|
||
// это означает повторное чтение env и пересборку HashMap<provider> на каждый HTTP-вызов,
|
||
// не только при реальном клонировании сервиса. Стоит завернуть `providers` в Arc и строить
|
||
// один раз в `new()`, чтобы `clone()` был дешёвым Arc::clone.
|
||
impl Clone for BillingService {
|
||
fn clone(&self) -> Self {
|
||
// Снова читаем из env при клонировании
|
||
let master_wallet = std::env::var("TON_MASTER_WALLET")
|
||
.expect("КРИТИЧЕСКАЯ ОШИБКА: TON_MASTER_WALLET не задан в .env!");
|
||
|
||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||
providers.insert(
|
||
"TON",
|
||
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
||
);
|
||
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
|
||
// not supported" (см. `initiate_payment`), а не уронит инстанс при
|
||
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
|
||
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
|
||
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
|
||
"https://testnet-pay.crypt.bot/api".to_string()
|
||
} else {
|
||
"https://pay.crypt.bot/api".to_string()
|
||
};
|
||
providers.insert(
|
||
"CRYPTOBOT",
|
||
Box::new(super::providers::cryptobot::CryptoBotProvider {
|
||
api_token,
|
||
base_url,
|
||
}),
|
||
);
|
||
}
|
||
|
||
Self {
|
||
repo: self.repo.clone(),
|
||
providers,
|
||
redis: self.redis.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BillingService {
|
||
pub fn new(repo: Arc<dyn AppRepository>, redis: Option<redis::aio::ConnectionManager>) -> Self {
|
||
// Читаем адрес из переменной окружения
|
||
let master_wallet = std::env::var("TON_MASTER_WALLET")
|
||
.expect("КРИТИЧЕСКАЯ ОШИБКА: TON_MASTER_WALLET не задан в .env!");
|
||
|
||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||
providers.insert(
|
||
"TON",
|
||
Box::new(super::providers::ton::TonProvider { master_wallet }),
|
||
);
|
||
// Опционален: без токена оплата через CryptoBot просто вернёт "provider
|
||
// not supported" (см. `initiate_payment`), а не уронит инстанс при
|
||
// старте — в отличие от обязательных секретов вроде TON_MASTER_WALLET.
|
||
if let Ok(api_token) = std::env::var("CRYPTOBOT_API_TOKEN") {
|
||
let base_url = if std::env::var("CRYPTOBOT_TESTNET").as_deref() == Ok("true") {
|
||
"https://testnet-pay.crypt.bot/api".to_string()
|
||
} else {
|
||
"https://pay.crypt.bot/api".to_string()
|
||
};
|
||
providers.insert(
|
||
"CRYPTOBOT",
|
||
Box::new(super::providers::cryptobot::CryptoBotProvider {
|
||
api_token,
|
||
base_url,
|
||
}),
|
||
);
|
||
}
|
||
|
||
Self {
|
||
repo,
|
||
providers,
|
||
redis,
|
||
}
|
||
}
|
||
|
||
/// Активные тарифы с USD-ценой — для плиточного выбора тарифа в боте.
|
||
pub async fn list_plans_usd(&self) -> Result<Vec<(String, i64)>, AppError> {
|
||
self.repo
|
||
.list_active_plans_usd()
|
||
.await
|
||
.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> {
|
||
let mut conn = self.redis.clone()?;
|
||
let cache_key = format!("ton_rate:{}", currency_lower);
|
||
let cached: Option<String> = conn.get(&cache_key).await.ok()?;
|
||
cached.and_then(|v| v.parse().ok())
|
||
}
|
||
|
||
async fn cache_ton_rate(&self, currency_lower: &str, rate: rust_decimal::Decimal) {
|
||
let Some(mut conn) = self.redis.clone() else {
|
||
return;
|
||
};
|
||
let cache_key = format!("ton_rate:{}", currency_lower);
|
||
let _: Result<(), _> = conn
|
||
.set_ex(&cache_key, rate.to_string(), TON_RATE_CACHE_TTL_SEC)
|
||
.await;
|
||
}
|
||
|
||
pub async fn get_subscription(&self, user_id: Uuid) -> Result<Option<Subscription>, AppError> {
|
||
self.repo
|
||
.get_subscription(user_id)
|
||
.await
|
||
.map_err(AppError::Database)
|
||
}
|
||
|
||
/// Этап 1: Генерация инвойса и платежной ссылки
|
||
pub async fn initiate_payment(
|
||
&self,
|
||
user: &User,
|
||
plan_name: &str,
|
||
provider_name: &str,
|
||
currency: &str,
|
||
) -> Result<serde_json::Value, AppError> {
|
||
let provider = self
|
||
.providers
|
||
.get(provider_name)
|
||
.ok_or_else(|| AppError::BusinessLogic("Payment provider not supported".into()))?;
|
||
|
||
let fiat_amount_minimal = self
|
||
.repo
|
||
.get_plan_price(plan_name, currency)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
|
||
})?;
|
||
|
||
// Курс TON нужен только самому TON-провайдеру — блокчейн понимает
|
||
// только nanoTON, а не фиат. Остальные провайдеры (CryptoBot: USDT
|
||
// 1:1 к USD; в будущем карточные эквайринги) биллятся напрямую в
|
||
// валюте `plan_prices`, без пересчёта.
|
||
let invoice_amount = if provider_name == "TON" {
|
||
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
|
||
let currency_lower = currency.to_lowercase();
|
||
let currency_upper = currency.to_uppercase();
|
||
|
||
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(¤cy_lower).await {
|
||
cached
|
||
} else {
|
||
let client = reqwest::Client::new();
|
||
// TonAPI и запрашивать, и отдавать валюту нужно по одному и тому же
|
||
// коду: раньше здесь были жёстко зашиты "usd,rub" в запросе (курс для
|
||
// любой другой валюты из plan_prices — CNY/TRY/IRR — никогда бы не
|
||
// нашёлся), а ответ разбирался по НИЖНЕМУ регистру, хотя TonAPI
|
||
// возвращает ключи валют в ВЕРХНЕМ (`prices.USD`, не `prices.usd`).
|
||
let url = format!(
|
||
"https://tonapi.io/v2/rates?tokens=ton¤cies={}",
|
||
currency_lower
|
||
);
|
||
let rate_res: serde_json::Value = client
|
||
.get(&url)
|
||
.send()
|
||
.await
|
||
.map_err(|e| {
|
||
AppError::Internal(format!("Ошибка сети при запросе курса: {}", e))
|
||
})?
|
||
.json()
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
|
||
|
||
let rate_f64 = rate_res["rates"]["TON"]["prices"][¤cy_upper]
|
||
.as_f64()
|
||
.ok_or_else(|| {
|
||
AppError::Internal("TonAPI не вернул курс для этой валюты".into())
|
||
})?;
|
||
|
||
let rate = rust_decimal::Decimal::from_f64(rate_f64)
|
||
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
|
||
|
||
self.cache_ton_rate(¤cy_lower, rate).await;
|
||
rate
|
||
};
|
||
// ----------------------------------------
|
||
|
||
Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
||
.map_err(AppError::BusinessLogic)?
|
||
} else {
|
||
fiat_amount_minimal
|
||
};
|
||
|
||
let invoice_id = self
|
||
.repo
|
||
.create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
|
||
.await?;
|
||
metrics::counter!("payments_total", "provider" => provider_name.to_string(), "status" => "initiated")
|
||
.increment(1);
|
||
|
||
let invoice_data = InvoiceData {
|
||
invoice_id,
|
||
amount: invoice_amount,
|
||
currency: currency.into(),
|
||
};
|
||
|
||
provider.create_checkout_session(&invoice_data).await
|
||
}
|
||
|
||
/// Этап 2: Вебхук или ручная проверка оплаты юзером.
|
||
/// `caller_id` — кто инициировал подтверждение (для проверки владения инвойсом).
|
||
pub async fn confirm_payment(
|
||
&self,
|
||
caller_id: Uuid,
|
||
invoice_id: Uuid,
|
||
external_tx_id: &str,
|
||
) -> Result<(), AppError> {
|
||
// 1. Достаем инвойс из БД по ID
|
||
let invoice = self
|
||
.repo
|
||
.get_invoice(invoice_id)
|
||
.await?
|
||
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
|
||
|
||
// Подтверждать инвойс может только его владелец. Иначе чужой юзер мог бы
|
||
// инициировать обращения к блокчейну по произвольным invoice_id (перебор/DoS).
|
||
if invoice.user_id != caller_id {
|
||
return Err(AppError::Unauthorized(
|
||
"Этот инвойс принадлежит другому оперативнику.".into(),
|
||
));
|
||
}
|
||
|
||
if invoice.status == "paid" {
|
||
return Ok(());
|
||
}
|
||
|
||
// 2. Получаем провайдера, которым был выставлен именно этот инвойс —
|
||
// раньше здесь был захардкожен "TON", что ломало подтверждение любого
|
||
// другого провайдера (см. `invoice.provider`, заполняется в `create_invoice`).
|
||
let provider = self
|
||
.providers
|
||
.get(invoice.provider.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::Internal(format!(
|
||
"Провайдер {} не сконфигурирован на этом инстансе",
|
||
invoice.provider
|
||
))
|
||
})?;
|
||
|
||
// 3. Провайдер лезет в блокчейн и проверяет транзакцию
|
||
let is_valid = provider
|
||
.verify_payment(external_tx_id, invoice.amount, &invoice.id.to_string()) // <--- ПЕРЕДАЕМ ID
|
||
.await?;
|
||
|
||
if !is_valid {
|
||
self.repo
|
||
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
|
||
.await?;
|
||
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "failed")
|
||
.increment(1);
|
||
return Err(AppError::BusinessLogic(
|
||
"Payment verification failed".into(),
|
||
));
|
||
}
|
||
|
||
// 4. Если всё четко - обновляем статус инвойса на PAID
|
||
self.repo
|
||
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
|
||
.await?;
|
||
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "paid")
|
||
.increment(1);
|
||
|
||
// 5. АКТИВИРУЕМ ТАРИФ
|
||
self.repo
|
||
.buy_subscription(invoice.user_id, &invoice.plan_name)
|
||
.await?;
|
||
|
||
// 6. Выдаем реферальный бонус (10% от суммы инвойса)
|
||
let revshare_bonus = invoice.amount / 10;
|
||
let _ = self
|
||
.repo
|
||
.reward_referrer(invoice.user_id, revshare_bonus)
|
||
.await;
|
||
|
||
// 7. Партнёрская комиссия — независимо от рефералки выше, отдельная
|
||
// система (см. modules::staff). Считаем от фиатной цены тарифа В
|
||
// ВАЛЮТЕ ИНВОЙСА через get_plan_price, а НЕ от invoice.amount:
|
||
// invoice.amount для TON — это nanoTON (+2% буфер на конвертацию),
|
||
// не фиат; для CryptoBot это фиат-центы 1:1, но полагаться на то,
|
||
// каким провайдером оплачен именно этот инвойс, неверно —
|
||
// get_plan_price(plan_name, currency) корректен одинаково для всех
|
||
// провайдеров. Ошибка здесь не должна ронять подтверждение платежа
|
||
// (та же resilience, что у reward_referrer выше).
|
||
if let Err(e) = self.record_partner_commission_if_referred(&invoice).await {
|
||
tracing::warn!(
|
||
"Не удалось начислить партнёрскую комиссию по инвойсу {}: {:?}",
|
||
invoice.id,
|
||
e
|
||
);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// См. комментарий в шаге 7 `confirm_payment` — вынесено отдельным
|
||
/// методом ради `?`-раннего-выхода без загрязнения основной функции.
|
||
async fn record_partner_commission_if_referred(
|
||
&self,
|
||
invoice: &crate::db::repository::InvoiceRecord,
|
||
) -> Result<(), AppError> {
|
||
let Some(buyer) = self.repo.get_user_by_id(invoice.user_id).await? else {
|
||
return Ok(());
|
||
};
|
||
let Some(partner_id) = buyer.referred_by_partner_id else {
|
||
return Ok(());
|
||
};
|
||
let Some(partner) = self.repo.get_user_by_id(partner_id).await? else {
|
||
return Ok(());
|
||
};
|
||
let Some(percent) = partner.commission_percent else {
|
||
return Ok(()); // партнёр без выставленного процента — молча пропускаем
|
||
};
|
||
let Some(fiat_amount_minimal) = self
|
||
.repo
|
||
.get_plan_price(&invoice.plan_name, &invoice.currency)
|
||
.await?
|
||
else {
|
||
return Ok(()); // тариф/валюта более не активны — пропускаем
|
||
};
|
||
|
||
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
|
||
let commission = (rust_decimal::Decimal::from_i64(fiat_amount_minimal).unwrap_or_default()
|
||
* percent
|
||
/ rust_decimal::Decimal::from(100))
|
||
.round()
|
||
.to_i64()
|
||
.unwrap_or(0);
|
||
|
||
if commission > 0 {
|
||
self.repo
|
||
.record_partner_commission(
|
||
partner_id,
|
||
invoice.id,
|
||
invoice.user_id,
|
||
&invoice.currency,
|
||
commission,
|
||
percent,
|
||
)
|
||
.await?;
|
||
metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone())
|
||
.increment(commission as u64);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Фоновая сверка блокчейна: находит pending-инвойсы, по которым уже прошла
|
||
/// оплата на мастер-кошелёк (но вебхук/ручное подтверждение потерялись),
|
||
/// и доводит их до статуса `paid`. Возвращает число добитых инвойсов.
|
||
pub async fn reconcile_pending_payments(&self) -> Result<u64, AppError> {
|
||
let pending = self
|
||
.repo
|
||
.get_pending_invoices()
|
||
.await
|
||
.map_err(AppError::Database)?;
|
||
|
||
if pending.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
|
||
let provider = self
|
||
.providers
|
||
.get("TON")
|
||
.ok_or_else(|| AppError::Internal("TON provider не сконфигурирован".into()))?;
|
||
|
||
// Снимок последних транзакций кошелька: comment(invoice_id) -> tx_hash
|
||
let txs = provider.get_recent_transactions().await?;
|
||
let mut hash_by_comment: HashMap<String, String> = HashMap::new();
|
||
for tx in &txs {
|
||
let comment = tx["in_msg"]["decoded_body"]["text"].as_str().unwrap_or("");
|
||
let hash = tx["hash"].as_str().unwrap_or("");
|
||
if !comment.is_empty() && !hash.is_empty() {
|
||
hash_by_comment
|
||
.entry(comment.to_string())
|
||
.or_insert_with(|| hash.to_string());
|
||
}
|
||
}
|
||
|
||
let mut confirmed = 0u64;
|
||
for invoice in pending {
|
||
if let Some(tx_hash) = hash_by_comment.get(&invoice.id.to_string()) {
|
||
// confirm_payment повторно валидирует сумму/адрес/коммент on-chain,
|
||
// так что ложное срабатывание по совпадению комментария исключено.
|
||
// Сверка системная — владельцем выступает сам пользователь инвойса.
|
||
match self
|
||
.confirm_payment(invoice.user_id, invoice.id, tx_hash)
|
||
.await
|
||
{
|
||
Ok(_) => {
|
||
confirmed += 1;
|
||
tracing::info!(
|
||
"[{}] Реконсиляция: добит потерянный платёж по инвойсу {}",
|
||
provider.provider_id(),
|
||
invoice.id
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Реконсиляция инвойса {} не удалась: {:?}", invoice.id, e)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(confirmed)
|
||
}
|
||
|
||
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
|
||
// Защита от Sybil-атаки: анонимные Ghost-аккаунты (вход по Seed) генерируются
|
||
// бесплатно и без лимита, поэтому им триал не положен — иначе любой скрипт
|
||
// штампует новые Seed и крутит бесконечный бесплатный VPN. Триал привязан к
|
||
// Telegram-личности (tg_id), которую так дёшево не размножить.
|
||
if user.tg_id.is_none() {
|
||
return Err(AppError::BusinessLogic(
|
||
"Пробный период доступен только для аккаунтов, привязанных к Telegram.".into(),
|
||
));
|
||
}
|
||
if user.has_used_trial {
|
||
return Err(AppError::TrialAlreadyUsed);
|
||
}
|
||
self.repo
|
||
.activate_trial(user.id)
|
||
.await
|
||
.map_err(AppError::Database)
|
||
}
|
||
|
||
pub async fn reset_expired_subscriptions(&self) -> Result<u64, AppError> {
|
||
self.repo
|
||
.reset_expired_subscriptions()
|
||
.await
|
||
.map_err(AppError::Database)
|
||
}
|
||
|
||
pub fn calculate_ton_invoice(
|
||
fiat_amount_minimal: i64, // В копейках/центах из БД
|
||
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
|
||
) -> Result<i64, String> {
|
||
use rust_decimal::prelude::ToPrimitive;
|
||
use rust_decimal::Decimal;
|
||
|
||
// 1. Создаем константу 100 безопасным способом (100.00)
|
||
let one_hundred = Decimal::new(100, 0);
|
||
|
||
// Избавляемся от копеек/центов, переводим в базовый фиат
|
||
let fiat_base = Decimal::from(fiat_amount_minimal) / one_hundred;
|
||
|
||
// 2. Считаем чистый объем TON
|
||
if ton_rate_to_currency.is_zero() {
|
||
return Err("Курс TON не может быть нулевым".to_string());
|
||
}
|
||
let ton_clean = fiat_base / ton_rate_to_currency;
|
||
|
||
// 3. Накидываем 2% буфера (умножаем на 1.02 -> 102 со сдвигом запятой на 2 знака)
|
||
let buffer_multiplier = Decimal::new(102, 2);
|
||
let ton_with_buffer = ton_clean * buffer_multiplier;
|
||
|
||
// 4. Переводим в системные nanoTON для блокчейна (умножаем на 1_000_000_000)
|
||
let nanoton_multiplier = Decimal::new(1_000_000_000, 0);
|
||
|
||
let nanotons = (ton_with_buffer * nanoton_multiplier)
|
||
.round()
|
||
.to_i64()
|
||
.ok_or_else(|| "Ошибка перевода типов Decimal в i64".to_string())?;
|
||
|
||
Ok(nanotons)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::db::repository::PgRepository;
|
||
use crate::modules::billing::providers::{InvoiceData, PaymentProvider};
|
||
use async_trait::async_trait;
|
||
use rust_decimal::Decimal;
|
||
use sqlx::PgPool;
|
||
|
||
// --- calculate_ton_invoice: чистая арифметика, без БД ---
|
||
|
||
#[test]
|
||
fn test_calculate_ton_invoice_applies_2_percent_buffer() {
|
||
// $10.00 (1000 центов) по курсу 2 USD/TON -> 5 TON чистых -> +2% буфер -> 5.1 TON
|
||
let rate = Decimal::new(2, 0);
|
||
let nanotons = BillingService::calculate_ton_invoice(1000, rate).unwrap();
|
||
assert_eq!(nanotons, 5_100_000_000);
|
||
}
|
||
|
||
#[test]
|
||
fn test_calculate_ton_invoice_zero_rate_errors() {
|
||
let err = BillingService::calculate_ton_invoice(1000, Decimal::ZERO).unwrap_err();
|
||
assert!(err.contains("не может быть нулевым"));
|
||
}
|
||
|
||
// --- confirm_payment: владение, идемпотентность, маршрутизация провайдера ---
|
||
|
||
struct FakeProvider {
|
||
id: &'static str,
|
||
verify_result: bool,
|
||
}
|
||
|
||
#[async_trait]
|
||
impl PaymentProvider for FakeProvider {
|
||
fn provider_id(&self) -> &'static str {
|
||
self.id
|
||
}
|
||
async fn create_checkout_session(
|
||
&self,
|
||
_invoice: &InvoiceData,
|
||
) -> Result<serde_json::Value, AppError> {
|
||
Ok(serde_json::json!({}))
|
||
}
|
||
async fn verify_payment(
|
||
&self,
|
||
_external_tx_id: &str,
|
||
_expected_amount: i64,
|
||
_expected_invoice_id: &str,
|
||
) -> Result<bool, AppError> {
|
||
Ok(self.verify_result)
|
||
}
|
||
}
|
||
|
||
fn service_with_fake_provider(
|
||
repo: Arc<dyn AppRepository>,
|
||
verify_result: bool,
|
||
) -> BillingService {
|
||
let mut providers: HashMap<&'static str, Box<dyn PaymentProvider>> = HashMap::new();
|
||
providers.insert(
|
||
"TEST",
|
||
Box::new(FakeProvider {
|
||
id: "TEST",
|
||
verify_result,
|
||
}),
|
||
);
|
||
BillingService {
|
||
repo,
|
||
providers,
|
||
redis: None,
|
||
}
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_confirm_payment_rejects_non_owner(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
let owner = repo.upsert_user(1, None).await.unwrap();
|
||
let stranger = repo.upsert_user(2, None).await.unwrap();
|
||
let invoice_id = repo
|
||
.create_invoice(owner.id, "GHOST_1M", "TEST", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
|
||
let err = svc
|
||
.confirm_payment(stranger.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||
|
||
// Инвойс не должен был измениться.
|
||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||
assert_eq!(invoice.status, "pending");
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_confirm_payment_idempotent_when_already_paid(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
// verify_result=false: если бы идемпотентность не сработала, второй вызов
|
||
// упал бы на верификации — но код обязан выйти раньше, на шаге "already paid".
|
||
let svc = service_with_fake_provider(repo.clone(), false);
|
||
|
||
let user = repo.upsert_user(1, None).await.unwrap();
|
||
let invoice_id = repo
|
||
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
repo.update_invoice_status(invoice_id, "paid", Some("tx0"))
|
||
.await
|
||
.unwrap();
|
||
|
||
svc.confirm_payment(user.id, invoice_id, "tx1")
|
||
.await
|
||
.expect("повторное подтверждение уже оплаченного инвойса должно быть no-op");
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_confirm_payment_unknown_provider_errors(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
let user = repo.upsert_user(1, None).await.unwrap();
|
||
// Инвойс выставлен провайдером, которого нет в HashMap этого инстанса —
|
||
// регрессионный тест на баг "confirm_payment жёстко ходил в TON независимо
|
||
// от invoice.provider".
|
||
let invoice_id = repo
|
||
.create_invoice(user.id, "GHOST_1M", "NOT_CONFIGURED", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
|
||
let err = svc
|
||
.confirm_payment(user.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, AppError::Internal(_)));
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_confirm_payment_failed_verification_marks_invoice_failed(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), false);
|
||
|
||
let user = repo.upsert_user(1, None).await.unwrap();
|
||
let invoice_id = repo
|
||
.create_invoice(user.id, "GHOST_1M", "TEST", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
|
||
let err = svc
|
||
.confirm_payment(user.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap_err();
|
||
assert!(matches!(err, AppError::BusinessLogic(_)));
|
||
|
||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||
assert_eq!(invoice.status, "failed");
|
||
|
||
// Подписка не должна была активироваться.
|
||
assert!(repo.get_subscription(user.id).await.unwrap().is_none());
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_confirm_payment_success_activates_subscription_and_rewards_referrer(
|
||
pool: PgPool,
|
||
) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
let referrer = repo.upsert_user(1, None).await.unwrap();
|
||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||
repo.create_referral(referrer.id, buyer.id).await.unwrap();
|
||
|
||
let invoice_id = repo
|
||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500) // $5.00
|
||
.await
|
||
.unwrap();
|
||
|
||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||
.await
|
||
.expect("успешная верификация должна пройти");
|
||
|
||
let invoice = repo.get_invoice(invoice_id).await.unwrap().unwrap();
|
||
assert_eq!(invoice.status, "paid");
|
||
|
||
let sub = repo
|
||
.get_subscription(buyer.id)
|
||
.await
|
||
.unwrap()
|
||
.expect("подписка должна активироваться");
|
||
assert_eq!(sub.plan_name, "GHOST_1M");
|
||
assert_eq!(sub.status, "active");
|
||
|
||
// 10% от суммы инвойса (500) = 50, зачисляется рефереру на баланс.
|
||
let referrer_updated = repo.get_user_by_id(referrer.id).await.unwrap().unwrap();
|
||
assert_eq!(referrer_updated.balance, 50);
|
||
}
|
||
|
||
// --- Партнёрская комиссия: считается от get_plan_price, не от invoice.amount ---
|
||
|
||
#[sqlx::test]
|
||
async fn test_partner_commission_uses_plan_price_not_invoice_amount(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
let partner = repo
|
||
.create_staff_user(
|
||
"partner1",
|
||
"hash",
|
||
"partner",
|
||
false,
|
||
Some(Decimal::new(10, 0)), // 10%
|
||
Some("p_abc123"),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||
.await
|
||
.unwrap();
|
||
|
||
// invoice.amount = 999999 — намеренно НЕ совпадает с реальной ценой тарифа
|
||
// (симулирует TON-инвойс, где amount — nanoTON, а не фиат), чтобы доказать,
|
||
// что комиссия считается через get_plan_price("GHOST_1M", "USD") = 500,
|
||
// а не от этого числа.
|
||
let invoice_id = repo
|
||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 999_999)
|
||
.await
|
||
.unwrap();
|
||
|
||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap();
|
||
|
||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||
// 10% от 500 (реальная цена GHOST_1M/USD) = 50, а НЕ 10% от 999999.
|
||
assert_eq!(usd_earnings.total_earned, 50);
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_partner_commission_idempotent_on_duplicate_confirm(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
let partner = repo
|
||
.create_staff_user(
|
||
"partner1",
|
||
"hash",
|
||
"partner",
|
||
false,
|
||
Some(Decimal::new(10, 0)),
|
||
Some("p_abc123"),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||
.await
|
||
.unwrap();
|
||
|
||
let invoice_id = repo
|
||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
|
||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap();
|
||
// Второй вызов — инвойс уже "paid", подтверждение — no-op (см. тест выше),
|
||
// поэтому record_partner_commission_if_referred не должен вызваться снова.
|
||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap();
|
||
|
||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||
let usd_earnings = earnings.iter().find(|e| e.currency == "USD").unwrap();
|
||
assert_eq!(
|
||
usd_earnings.total_earned, 50,
|
||
"комиссия не должна начислиться дважды"
|
||
);
|
||
}
|
||
|
||
#[sqlx::test]
|
||
async fn test_partner_commission_skipped_without_percent(pool: PgPool) {
|
||
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool));
|
||
let svc = service_with_fake_provider(repo.clone(), true);
|
||
|
||
// Партнёр без выставленного процента — комиссия не должна начисляться,
|
||
// и confirm_payment не должен упасть.
|
||
let partner = repo
|
||
.create_staff_user("partner1", "hash", "partner", false, None, Some("p_abc123"))
|
||
.await
|
||
.unwrap();
|
||
let buyer = repo.upsert_user(2, None).await.unwrap();
|
||
repo.set_referred_by_partner(buyer.id, partner.id)
|
||
.await
|
||
.unwrap();
|
||
|
||
let invoice_id = repo
|
||
.create_invoice(buyer.id, "GHOST_1M", "TEST", "USD", 500)
|
||
.await
|
||
.unwrap();
|
||
|
||
svc.confirm_payment(buyer.id, invoice_id, "tx1")
|
||
.await
|
||
.unwrap();
|
||
|
||
let earnings = repo.get_partner_earnings(partner.id).await.unwrap();
|
||
assert!(earnings.is_empty());
|
||
}
|
||
}
|