//! Payment-related dashboard tab handlers. use axum::extract::{Query, State}; use axum::response::IntoResponse; use crate::{ Billing, auth::AuthUser, constants::DASHBOARD_TRANSACTION_LIMIT, db, error::Result, helpers, templates::{ BuyerContactsPartialTemplate, PayoutSummaryPartialTemplate, TransactionsTableTemplate, UserPaymentsTabTemplate, }, types::{BuyerContact, PayoutSummary, TipReceived, Transaction, User}, }; use sqlx::PgPool; /// Render the HTMX partial for the dashboard payments tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( State(db): State, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(crate::error::AppError::NotFound)?; let user = User::from(&db_user); let incoming_txs = db::transactions::get_transactions_by_seller( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; let outgoing_txs = db::transactions::get_transactions_by_buyer( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; let transactions = super::super::super::collect_transactions(&incoming_txs, &outgoing_txs); let db_tips = db::tips::get_tips_received(&db, session_user.id, 20, 0).await?; let tips_total_cents = db::tips::total_tips_received(&db, session_user.id).await?; let tips_count = db::tips::count_tips_received(&db, session_user.id).await?; let tips_received: Vec = db_tips .iter() .map(|t| TipReceived { date: t.created_at.format("%Y-%m-%d").to_string(), tipper_name: t .tipper_display_name .clone() .unwrap_or_else(|| t.tipper_username.clone()), amount: helpers::format_price(t.amount_cents), message: t.message.clone(), }) .collect(); // Revenue splits let splits_incoming_cents = db::project_members::total_split_revenue(&db, session_user.id).await?; let splits_incoming_count = db::project_members::count_splits_for_recipient(&db, session_user.id).await?; let splits_outgoing_cents = db::project_members::total_split_obligations(&db, session_user.id).await?; Ok(UserPaymentsTabTemplate { user, transactions, tips_received, tips_total: helpers::format_revenue(tips_total_cents), tips_count, splits_incoming_total: helpers::format_revenue(splits_incoming_cents), splits_incoming_count, splits_outgoing_total: helpers::format_revenue(splits_outgoing_cents), can_create_projects: session_user.can_create_projects, }) } /// Render the HTMX partial for the Stripe payout summary (lazy-loaded in the /// Payments tab). Split out of `dashboard_tab_payments` so the tab renders /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2). #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary( State(db): State, State(payments): State, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(crate::error::AppError::NotFound)?; let user = User::from(&db_user); let payout_summary = match (&payments.stripe, &user.stripe_account_id) { (Some(stripe), Some(account_id)) => match stripe.get_balance(account_id).await { Ok(balance) => Some(PayoutSummary { available: helpers::format_revenue(balance.available_cents), pending: helpers::format_revenue(balance.pending_cents), }), Err(e) => { tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary"); None } }, _ => None, }; Ok(PayoutSummaryPartialTemplate { payout_summary, stripe_payouts_enabled: user.stripe_payouts_enabled, }) } /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab). #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts( State(db): State, AuthUser(session_user): AuthUser, ) -> Result { let contacts = db::transactions::get_seller_contacts(&db, session_user.id).await?; let contact_views: Vec = contacts .into_iter() .map(|c| BuyerContact { username: c.username, email: c.email, total_purchases: c.total_purchases, total_spent: format!( "${}.{:02}", c.total_spent_cents / 100, c.total_spent_cents.unsigned_abs() % 100 ), last_purchase: c.last_purchase_at.format("%b %-d, %Y").to_string(), }) .collect(); Ok(BuyerContactsPartialTemplate { contacts: contact_views, }) } /// Render the HTMX partial for the filtered transactions table. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")] pub(in crate::routes::pages::dashboard) async fn dashboard_transactions( State(db): State, AuthUser(session_user): AuthUser, Query(query): Query, ) -> Result { // Only fetch the direction the user asked for let transactions = match query.r#type.as_deref() { Some("incoming") => { let txs = db::transactions::get_transactions_by_seller( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; txs.iter().map(Transaction::from_sale).collect() } Some("outgoing") => { let txs = db::transactions::get_transactions_by_buyer( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; txs.iter().map(Transaction::from_purchase).collect() } _ => { let incoming = db::transactions::get_transactions_by_seller( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; let outgoing = db::transactions::get_transactions_by_buyer( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT), ) .await?; super::super::super::collect_transactions(&incoming, &outgoing) } }; Ok(TransactionsTableTemplate { transactions }) }