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, providers: HashMap<&'static str, Box>, // Redis — только кеш, не источник истины: если он недоступен, просто // ходим в TonAPI напрямую на каждый запрос (как было раньше). redis: Option, } // Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко. // TODO: axum клонирует ApiState (а значит и BillingService) на каждый запрос — // это означает повторное чтение env и пересборку HashMap на каждый 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> = 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, redis: Option) -> Self { // Читаем адрес из переменной окружения let master_wallet = std::env::var("TON_MASTER_WALLET") .expect("КРИТИЧЕСКАЯ ОШИБКА: TON_MASTER_WALLET не задан в .env!"); let mut providers: HashMap<&'static str, Box> = 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, 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 { let mut conn = self.redis.clone()?; let cache_key = format!("ton_rate:{}", currency_lower); let cached: Option = 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, 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 { 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 { 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 = 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 { 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 { 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 { Ok(serde_json::json!({})) } async fn verify_payment( &self, _external_tx_id: &str, _expected_amount: i64, _expected_invoice_id: &str, ) -> Result { Ok(self.verify_result) } } fn service_with_fake_provider( repo: Arc, verify_result: bool, ) -> BillingService { let mut providers: HashMap<&'static str, Box> = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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()); } }