Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+387
-8
@@ -16,6 +16,8 @@ pub struct InvoiceRecord {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub plan_name: String,
|
||||
pub provider: String,
|
||||
pub currency: String,
|
||||
pub amount: i64,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -112,6 +114,12 @@ pub trait AppRepository: Send + Sync {
|
||||
currency: &str,
|
||||
) -> Result<Option<i64>, sqlx::Error>;
|
||||
|
||||
/// Активные тарифы с USD-ценой (в центах) — для плиточного выбора тарифа
|
||||
/// в боте (см. `bot.rs::menu_pay_crypto`). USD выбран как единственная
|
||||
/// валюта, гарантированно заведённая на каждый план (см. сид в
|
||||
/// `0001_init.sql`), а не потому что CryptoBot требует именно её.
|
||||
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error>;
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -148,6 +156,74 @@ pub trait AppRepository: Send + Sync {
|
||||
async fn get_referral_stats(&self, user_id: Uuid) -> Result<(i64, i64), sqlx::Error>;
|
||||
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
|
||||
|
||||
// --- РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER ---
|
||||
/// Создаёт Owner/Moderator/Partner-аккаунт (логин+пароль, не Telegram/seed).
|
||||
/// `can_manage_nodes` осмыслен только для "moderator", `commission_percent`/
|
||||
/// `partner_code` — только для "partner" (для остальных ролей передавать `None`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_staff_user(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
role: &str,
|
||||
can_manage_nodes: bool,
|
||||
commission_percent: Option<rust_decimal::Decimal>,
|
||||
partner_code: Option<&str>,
|
||||
) -> Result<User, sqlx::Error>;
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
|
||||
async fn set_can_manage_nodes(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
enabled: bool,
|
||||
) -> Result<Option<User>, sqlx::Error>;
|
||||
/// `role_filter` — `None` = все staff-роли (owner+moderator+partner).
|
||||
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error>;
|
||||
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error>;
|
||||
/// Постоянная привязка покупателя к партнёру при первой регистрации по
|
||||
/// диплинку. No-op (`Ok(false)`), если уже привязан — first-wins.
|
||||
async fn set_referred_by_partner(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
partner_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error>;
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn record_partner_commission(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
invoice_id: Uuid,
|
||||
user_id: Uuid,
|
||||
currency: &str,
|
||||
amount: i64,
|
||||
percent_at_time: rust_decimal::Decimal,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
/// Разбивка заработка/вывода партнёра по валютам (без конвертации).
|
||||
async fn get_partner_earnings(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error>;
|
||||
async fn create_withdrawal_request(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
currency: &str,
|
||||
amount: i64,
|
||||
note: Option<&str>,
|
||||
) -> Result<WithdrawalRequest, sqlx::Error>;
|
||||
/// `status_filter` — `None` = все заявки (используется owner/moderator).
|
||||
async fn list_withdrawal_requests(
|
||||
&self,
|
||||
status_filter: Option<&str>,
|
||||
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
|
||||
async fn list_withdrawal_requests_for_partner(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
) -> Result<Vec<WithdrawalRequest>, sqlx::Error>;
|
||||
async fn update_withdrawal_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_status: &str,
|
||||
processed_by: Uuid,
|
||||
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
|
||||
|
||||
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
||||
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
||||
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
||||
@@ -200,7 +276,8 @@ impl AppRepository for PgRepository {
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
|
||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at"
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
|
||||
)
|
||||
.bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await
|
||||
}
|
||||
@@ -209,7 +286,8 @@ impl AppRepository for PgRepository {
|
||||
async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
@@ -221,7 +299,8 @@ impl AppRepository for PgRepository {
|
||||
async fn get_user_by_tg_id(&self, tg_id: i64) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE tg_id = $1"
|
||||
)
|
||||
.bind(tg_id)
|
||||
@@ -234,7 +313,8 @@ impl AppRepository for PgRepository {
|
||||
sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
|
||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at"
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
|
||||
)
|
||||
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
|
||||
}
|
||||
@@ -243,7 +323,8 @@ impl AppRepository for PgRepository {
|
||||
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE seed_hash = $1"
|
||||
)
|
||||
.bind(hash)
|
||||
@@ -277,7 +358,8 @@ impl AppRepository for PgRepository {
|
||||
sqlx::query_as::<_, User>(
|
||||
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
|
||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at",
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id",
|
||||
)
|
||||
.bind(limit_bytes)
|
||||
.bind(user_id)
|
||||
@@ -583,6 +665,18 @@ impl AppRepository for PgRepository {
|
||||
Ok(res.map(|r| r.0))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn list_active_plans_usd(&self) -> Result<Vec<(String, i64)>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
"SELECT pp.plan_name, pp.amount FROM plan_prices pp \
|
||||
JOIN plans p ON p.name = pp.plan_name \
|
||||
WHERE pp.currency = 'USD' AND pp.is_active = TRUE AND p.is_active = TRUE \
|
||||
ORDER BY pp.amount ASC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
@@ -611,7 +705,7 @@ impl AppRepository for PgRepository {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_invoice(&self, invoice_id: Uuid) -> Result<Option<InvoiceRecord>, sqlx::Error> {
|
||||
sqlx::query_as::<_, InvoiceRecord>(
|
||||
"SELECT id, user_id, plan_name, amount, status FROM invoices WHERE id = $1",
|
||||
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices WHERE id = $1",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -621,7 +715,7 @@ impl AppRepository for PgRepository {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_pending_invoices(&self) -> Result<Vec<InvoiceRecord>, sqlx::Error> {
|
||||
sqlx::query_as::<_, InvoiceRecord>(
|
||||
"SELECT id, user_id, plan_name, amount, status FROM invoices \
|
||||
"SELECT id, user_id, plan_name, provider, currency, amount, status FROM invoices \
|
||||
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '24 hours' \
|
||||
ORDER BY created_at DESC LIMIT 100",
|
||||
)
|
||||
@@ -828,6 +922,291 @@ impl AppRepository for PgRepository {
|
||||
Ok(reward)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// РОЛЕВАЯ АДМИНКА: OWNER / MODERATOR / PARTNER
|
||||
// =====================================================================
|
||||
|
||||
#[instrument(skip(self, password_hash))]
|
||||
async fn create_staff_user(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
role: &str,
|
||||
can_manage_nodes: bool,
|
||||
commission_percent: Option<rust_decimal::Decimal>,
|
||||
partner_code: Option<&str>,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (id, username, password_hash, role, can_manage_nodes, commission_percent, partner_code) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(role)
|
||||
.bind(can_manage_nodes)
|
||||
.bind(commission_percent)
|
||||
.bind(partner_code)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn set_can_manage_nodes(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
enabled: bool,
|
||||
) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
|
||||
RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id"
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
|
||||
let roles: Vec<&str> = match role_filter {
|
||||
Some(r) => vec![r],
|
||||
None => vec!["owner", "moderator", "partner"],
|
||||
};
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE role = ANY($1) ORDER BY role, username"
|
||||
)
|
||||
.bind(&roles)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
||||
data_limit_bytes, data_used_bytes, data_period_reset_at, \
|
||||
username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \
|
||||
FROM users WHERE partner_code = $1 AND role = 'partner'"
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn set_referred_by_partner(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
partner_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
// first-wins: не перезаписывает уже проставленную привязку.
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET referred_by_partner_id = $1 \
|
||||
WHERE id = $2 AND referred_by_partner_id IS NULL",
|
||||
)
|
||||
.bind(partner_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn record_partner_commission(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
invoice_id: Uuid,
|
||||
user_id: Uuid,
|
||||
currency: &str,
|
||||
amount: i64,
|
||||
percent_at_time: rust_decimal::Decimal,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO partner_commissions (id, partner_id, invoice_id, user_id, currency, amount, percent_at_time) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
ON CONFLICT (invoice_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(partner_id)
|
||||
.bind(invoice_id)
|
||||
.bind(user_id)
|
||||
.bind(currency)
|
||||
.bind(amount)
|
||||
.bind(percent_at_time)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn get_partner_earnings(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
) -> Result<Vec<PartnerEarningsByCurrency>, sqlx::Error> {
|
||||
// Три отдельных сгруппированных запроса и слияние в Rust — проще и
|
||||
// надёжнее одного сложного JOIN, объёмы тут крошечные.
|
||||
let earned_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM partner_commissions \
|
||||
WHERE partner_id = $1 GROUP BY currency",
|
||||
)
|
||||
.bind(partner_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let withdrawn_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||
WHERE partner_id = $1 AND status IN ('approved', 'paid') GROUP BY currency",
|
||||
)
|
||||
.bind(partner_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let pending_rows: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT currency, COALESCE(SUM(amount), 0)::BIGINT FROM withdrawal_requests \
|
||||
WHERE partner_id = $1 AND status = 'pending' GROUP BY currency",
|
||||
)
|
||||
.bind(partner_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
fn entry_for(
|
||||
map: &mut std::collections::BTreeMap<String, PartnerEarningsByCurrency>,
|
||||
currency: String,
|
||||
) -> &mut PartnerEarningsByCurrency {
|
||||
map.entry(currency.clone())
|
||||
.or_insert_with(|| PartnerEarningsByCurrency {
|
||||
currency,
|
||||
total_earned: 0,
|
||||
total_withdrawn: 0,
|
||||
pending_withdrawal: 0,
|
||||
available: 0,
|
||||
})
|
||||
}
|
||||
|
||||
let mut by_currency: std::collections::BTreeMap<String, PartnerEarningsByCurrency> =
|
||||
std::collections::BTreeMap::new();
|
||||
for (currency, amount) in earned_rows {
|
||||
entry_for(&mut by_currency, currency).total_earned = amount;
|
||||
}
|
||||
for (currency, amount) in withdrawn_rows {
|
||||
entry_for(&mut by_currency, currency).total_withdrawn = amount;
|
||||
}
|
||||
for (currency, amount) in pending_rows {
|
||||
entry_for(&mut by_currency, currency).pending_withdrawal = amount;
|
||||
}
|
||||
|
||||
Ok(by_currency
|
||||
.into_values()
|
||||
.map(|mut e| {
|
||||
e.available = e.total_earned - e.total_withdrawn - e.pending_withdrawal;
|
||||
e
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn create_withdrawal_request(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
currency: &str,
|
||||
amount: i64,
|
||||
note: Option<&str>,
|
||||
) -> Result<WithdrawalRequest, sqlx::Error> {
|
||||
sqlx::query_as::<_, WithdrawalRequest>(
|
||||
"INSERT INTO withdrawal_requests (id, partner_id, currency, amount, note) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(partner_id)
|
||||
.bind(currency)
|
||||
.bind(amount)
|
||||
.bind(note)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn list_withdrawal_requests(
|
||||
&self,
|
||||
status_filter: Option<&str>,
|
||||
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
|
||||
match status_filter {
|
||||
Some(status) => {
|
||||
sqlx::query_as::<_, WithdrawalRequest>(
|
||||
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||
FROM withdrawal_requests WHERE status = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(status)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, WithdrawalRequest>(
|
||||
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||
FROM withdrawal_requests ORDER BY (status = 'pending') DESC, created_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn list_withdrawal_requests_for_partner(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
) -> Result<Vec<WithdrawalRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, WithdrawalRequest>(
|
||||
"SELECT id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by \
|
||||
FROM withdrawal_requests WHERE partner_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(partner_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn update_withdrawal_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_status: &str,
|
||||
processed_by: Uuid,
|
||||
) -> Result<Option<WithdrawalRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, WithdrawalRequest>(
|
||||
"UPDATE withdrawal_requests SET status = $1, processed_at = NOW(), processed_by = $2 \
|
||||
WHERE id = $3 \
|
||||
RETURNING id, partner_id, currency, amount, status, note, created_at, processed_at, processed_by",
|
||||
)
|
||||
.bind(new_status)
|
||||
.bind(processed_by)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// VPN УЗЛЫ (CONTROL PLANE)
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user