Files
netrunner-backend/src/modules/billing/service.rs
T
nineap d91aef012e
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
Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
- 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-цене
  тарифа, менять валюту списания нельзя, не меняя сам провайдер).
2026-07-11 00:55:03 +07:00

831 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&currency_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&currencies={}",
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"][&currency_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(&currency_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());
}
}