//! Payment-related dashboard tab handlers. use crate::extractors::ValidatedQuery; use axum::extract::State; use axum::response::IntoResponse; use crate::{ auth::AuthUser, constants::DASHBOARD_TRANSACTION_LIMIT, db, error::Result, helpers, templates::{TransactionsTableTemplate, UserPaymentsTabTemplate}, types::{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, session: tower_sessions::Session, AuthUser(session_user): AuthUser, ) -> Result { let csrf_token = crate::helpers::get_csrf_token(&session).await; build_payments(&db, csrf_token, &session_user).await } /// The payments tab's contents, without the transport around them. /// /// Split out 2026-08-19 for the described strip (`6b24f2df` step 5). This is the /// tab a reader who cannot create projects opens on, so the page renders it on /// every such load rather than only on a deep link. It takes the CSRF token /// already read rather than the session, so the page does not read it twice. pub(in crate::routes::pages::dashboard) async fn build_payments( db: &PgPool, csrf_token: crate::templates::CsrfTokenOption, session_user: &crate::auth::SessionUser, ) -> 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, db_user.settlement_currency), 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?; let pending_invitations = db::project_members::get_pending_invitations(db, session_user.id).await?; let splits_incoming_foreign = splits_incoming_cents .iter() .any(|(c, _)| c != db_user.settlement_currency); Ok(UserPaymentsTabTemplate { csrf_token, user, transactions, tips_received, tips_total: helpers::format_revenue(tips_total_cents, db_user.settlement_currency), tips_count, // Incoming splits are denominated in the *paying* project's currency, // which belongs to its owner, so this is the one figure on a creator's // own dashboard that can legitimately be in someone else's money. splits_incoming_total: splits_incoming_cents.display(db_user.settlement_currency), // True when a split is paid in a currency this creator does not settle // in: Stripe converts at their payout and they carry that cost. Said // here because there is no acceptance step at which to say it earlier. splits_incoming_foreign, pending_invitations, own_currency: db_user.settlement_currency, splits_incoming_count, // Outgoing obligations are on this creator's own projects, so they are // denominated in this creator's own currency. splits_outgoing_total: helpers::format_revenue( splits_outgoing_cents, db_user.settlement_currency, ), splits_outgoing_any: splits_outgoing_cents != 0, can_create_projects: session_user.can_create_projects, }) } /// 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, ValidatedQuery(query): ValidatedQuery, ) -> 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 }) }