Skip to main content

max / makenotwork

6.3 KB · 166 lines History Blame Raw
1 //! Payment-related dashboard tab handlers.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::extract::State;
5 use axum::response::IntoResponse;
6
7 use crate::{
8 auth::AuthUser,
9 constants::DASHBOARD_TRANSACTION_LIMIT,
10 db,
11 error::Result,
12 helpers,
13 templates::{TransactionsTableTemplate, UserPaymentsTabTemplate},
14 types::{TipReceived, Transaction, User},
15 };
16 use sqlx::PgPool;
17
18 /// Render the HTMX partial for the dashboard payments tab.
19 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")]
20 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
21 State(db): State<PgPool>,
22 session: tower_sessions::Session,
23 AuthUser(session_user): AuthUser,
24 ) -> Result<impl IntoResponse> {
25 let csrf_token = crate::helpers::get_csrf_token(&session).await;
26 build_payments(&db, csrf_token, &session_user).await
27 }
28
29 /// The payments tab's contents, without the transport around them.
30 ///
31 /// Split out 2026-08-19 for the described strip (`6b24f2df` step 5). This is the
32 /// tab a reader who cannot create projects opens on, so the page renders it on
33 /// every such load rather than only on a deep link. It takes the CSRF token
34 /// already read rather than the session, so the page does not read it twice.
35 pub(in crate::routes::pages::dashboard) async fn build_payments(
36 db: &PgPool,
37 csrf_token: crate::templates::CsrfTokenOption,
38 session_user: &crate::auth::SessionUser,
39 ) -> Result<UserPaymentsTabTemplate> {
40 let db_user = db::users::get_user_by_id(db, session_user.id)
41 .await?
42 .ok_or(crate::error::AppError::NotFound)?;
43
44 let user = User::from(&db_user);
45
46 let incoming_txs = db::transactions::get_transactions_by_seller(
47 db,
48 session_user.id,
49 Some(DASHBOARD_TRANSACTION_LIMIT),
50 )
51 .await?;
52 let outgoing_txs = db::transactions::get_transactions_by_buyer(
53 db,
54 session_user.id,
55 Some(DASHBOARD_TRANSACTION_LIMIT),
56 )
57 .await?;
58 let transactions = super::super::super::collect_transactions(&incoming_txs, &outgoing_txs);
59
60 let db_tips = db::tips::get_tips_received(db, session_user.id, 20, 0).await?;
61 let tips_total_cents = db::tips::total_tips_received(db, session_user.id).await?;
62 let tips_count = db::tips::count_tips_received(db, session_user.id).await?;
63 let tips_received: Vec<TipReceived> = db_tips
64 .iter()
65 .map(|t| TipReceived {
66 date: t.created_at.format("%Y-%m-%d").to_string(),
67 tipper_name: t
68 .tipper_display_name
69 .clone()
70 .unwrap_or_else(|| t.tipper_username.clone()),
71 amount: helpers::format_price(t.amount_cents, db_user.settlement_currency),
72 message: t.message.clone(),
73 })
74 .collect();
75
76 // Revenue splits
77 let splits_incoming_cents =
78 db::project_members::total_split_revenue(db, session_user.id).await?;
79 let splits_incoming_count =
80 db::project_members::count_splits_for_recipient(db, session_user.id).await?;
81 let splits_outgoing_cents =
82 db::project_members::total_split_obligations(db, session_user.id).await?;
83
84 let pending_invitations =
85 db::project_members::get_pending_invitations(db, session_user.id).await?;
86
87 let splits_incoming_foreign = splits_incoming_cents
88 .iter()
89 .any(|(c, _)| c != db_user.settlement_currency);
90
91 Ok(UserPaymentsTabTemplate {
92 csrf_token,
93 user,
94 transactions,
95 tips_received,
96 tips_total: helpers::format_revenue(tips_total_cents, db_user.settlement_currency),
97 tips_count,
98 // Incoming splits are denominated in the *paying* project's currency,
99 // which belongs to its owner, so this is the one figure on a creator's
100 // own dashboard that can legitimately be in someone else's money.
101 splits_incoming_total: splits_incoming_cents.display(db_user.settlement_currency),
102 // True when a split is paid in a currency this creator does not settle
103 // in: Stripe converts at their payout and they carry that cost. Said
104 // here because there is no acceptance step at which to say it earlier.
105 splits_incoming_foreign,
106 pending_invitations,
107 own_currency: db_user.settlement_currency,
108 splits_incoming_count,
109 // Outgoing obligations are on this creator's own projects, so they are
110 // denominated in this creator's own currency.
111 splits_outgoing_total: helpers::format_revenue(
112 splits_outgoing_cents,
113 db_user.settlement_currency,
114 ),
115 splits_outgoing_any: splits_outgoing_cents != 0,
116 can_create_projects: session_user.can_create_projects,
117 })
118 }
119
120 /// Render the HTMX partial for the filtered transactions table.
121 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")]
122 pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
123 State(db): State<PgPool>,
124 AuthUser(session_user): AuthUser,
125 ValidatedQuery(query): ValidatedQuery<super::super::super::TransactionQuery>,
126 ) -> Result<impl IntoResponse> {
127 // Only fetch the direction the user asked for
128 let transactions = match query.r#type.as_deref() {
129 Some("incoming") => {
130 let txs = db::transactions::get_transactions_by_seller(
131 &db,
132 session_user.id,
133 Some(DASHBOARD_TRANSACTION_LIMIT),
134 )
135 .await?;
136 txs.iter().map(Transaction::from_sale).collect()
137 }
138 Some("outgoing") => {
139 let txs = db::transactions::get_transactions_by_buyer(
140 &db,
141 session_user.id,
142 Some(DASHBOARD_TRANSACTION_LIMIT),
143 )
144 .await?;
145 txs.iter().map(Transaction::from_purchase).collect()
146 }
147 _ => {
148 let incoming = db::transactions::get_transactions_by_seller(
149 &db,
150 session_user.id,
151 Some(DASHBOARD_TRANSACTION_LIMIT),
152 )
153 .await?;
154 let outgoing = db::transactions::get_transactions_by_buyer(
155 &db,
156 session_user.id,
157 Some(DASHBOARD_TRANSACTION_LIMIT),
158 )
159 .await?;
160 super::super::super::collect_transactions(&incoming, &outgoing)
161 }
162 };
163
164 Ok(TransactionsTableTemplate { transactions })
165 }
166