Skip to main content

max / makenotwork

9.0 KB · 238 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 Billing,
9 auth::AuthUser,
10 constants::DASHBOARD_TRANSACTION_LIMIT,
11 db,
12 error::Result,
13 helpers,
14 templates::{
15 BuyerContactsPartialTemplate, PayoutSummaryPartialTemplate, TransactionsTableTemplate,
16 UserPaymentsTabTemplate,
17 },
18 types::{BuyerContact, PayoutSummary, TipReceived, Transaction, User},
19 };
20 use sqlx::PgPool;
21
22 /// Render the HTMX partial for the dashboard payments tab.
23 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")]
24 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
25 State(db): State<PgPool>,
26 session: tower_sessions::Session,
27 AuthUser(session_user): AuthUser,
28 ) -> Result<impl IntoResponse> {
29 let csrf_token = crate::helpers::get_csrf_token(&session).await;
30 build_payments(&db, csrf_token, &session_user).await
31 }
32
33 /// The payments tab's contents, without the transport around them.
34 ///
35 /// Split out 2026-08-19 for the described strip (`6b24f2df` step 5). This is the
36 /// tab a reader who cannot create projects opens on, so the page renders it on
37 /// every such load rather than only on a deep link. It takes the CSRF token
38 /// already read rather than the session, so the page does not read it twice.
39 pub(in crate::routes::pages::dashboard) async fn build_payments(
40 db: &PgPool,
41 csrf_token: crate::templates::CsrfTokenOption,
42 session_user: &crate::auth::SessionUser,
43 ) -> Result<UserPaymentsTabTemplate> {
44 let db_user = db::users::get_user_by_id(db, session_user.id)
45 .await?
46 .ok_or(crate::error::AppError::NotFound)?;
47
48 let user = User::from(&db_user);
49
50 let incoming_txs = db::transactions::get_transactions_by_seller(
51 db,
52 session_user.id,
53 Some(DASHBOARD_TRANSACTION_LIMIT),
54 )
55 .await?;
56 let outgoing_txs = db::transactions::get_transactions_by_buyer(
57 db,
58 session_user.id,
59 Some(DASHBOARD_TRANSACTION_LIMIT),
60 )
61 .await?;
62 let transactions = super::super::super::collect_transactions(&incoming_txs, &outgoing_txs);
63
64 let db_tips = db::tips::get_tips_received(db, session_user.id, 20, 0).await?;
65 let tips_total_cents = db::tips::total_tips_received(db, session_user.id).await?;
66 let tips_count = db::tips::count_tips_received(db, session_user.id).await?;
67 let tips_received: Vec<TipReceived> = db_tips
68 .iter()
69 .map(|t| TipReceived {
70 date: t.created_at.format("%Y-%m-%d").to_string(),
71 tipper_name: t
72 .tipper_display_name
73 .clone()
74 .unwrap_or_else(|| t.tipper_username.clone()),
75 amount: helpers::format_price(t.amount_cents, db_user.settlement_currency),
76 message: t.message.clone(),
77 })
78 .collect();
79
80 // Revenue splits
81 let splits_incoming_cents =
82 db::project_members::total_split_revenue(db, session_user.id).await?;
83 let splits_incoming_count =
84 db::project_members::count_splits_for_recipient(db, session_user.id).await?;
85 let splits_outgoing_cents =
86 db::project_members::total_split_obligations(db, session_user.id).await?;
87
88 let pending_invitations =
89 db::project_members::get_pending_invitations(db, session_user.id).await?;
90
91 let splits_incoming_foreign = splits_incoming_cents
92 .iter()
93 .any(|(c, _)| c != db_user.settlement_currency);
94
95 Ok(UserPaymentsTabTemplate {
96 csrf_token,
97 user,
98 transactions,
99 tips_received,
100 tips_total: helpers::format_revenue(tips_total_cents, db_user.settlement_currency),
101 tips_count,
102 // Incoming splits are denominated in the *paying* project's currency,
103 // which belongs to its owner, so this is the one figure on a creator's
104 // own dashboard that can legitimately be in someone else's money.
105 splits_incoming_total: splits_incoming_cents.display(db_user.settlement_currency),
106 // True when a split is paid in a currency this creator does not settle
107 // in: Stripe converts at their payout and they carry that cost. Said
108 // here because there is no acceptance step at which to say it earlier.
109 splits_incoming_foreign,
110 pending_invitations,
111 own_currency: db_user.settlement_currency,
112 splits_incoming_count,
113 // Outgoing obligations are on this creator's own projects, so they are
114 // denominated in this creator's own currency.
115 splits_outgoing_total: helpers::format_revenue(
116 splits_outgoing_cents,
117 db_user.settlement_currency,
118 ),
119 splits_outgoing_any: splits_outgoing_cents != 0,
120 can_create_projects: session_user.can_create_projects,
121 })
122 }
123
124 /// Render the HTMX partial for the Stripe payout summary (lazy-loaded in the
125 /// Payments tab). Split out of `dashboard_tab_payments` so the tab renders
126 /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2).
127 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")]
128 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary(
129 State(db): State<PgPool>,
130 State(payments): State<Billing>,
131 AuthUser(session_user): AuthUser,
132 ) -> Result<impl IntoResponse> {
133 let db_user = db::users::get_user_by_id(&db, session_user.id)
134 .await?
135 .ok_or(crate::error::AppError::NotFound)?;
136 let user = User::from(&db_user);
137
138 let payout_summary = match (&payments.stripe, &user.stripe_account_id) {
139 (Some(stripe), Some(account_id)) => match stripe
140 .get_balance(account_id, db_user.settlement_currency)
141 .await
142 {
143 Ok(balance) => Some(PayoutSummary {
144 available: helpers::format_revenue(
145 balance.available_cents,
146 db_user.settlement_currency,
147 ),
148 pending: helpers::format_revenue(
149 balance.pending_cents,
150 db_user.settlement_currency,
151 ),
152 }),
153 Err(e) => {
154 tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
155 None
156 }
157 },
158 _ => None,
159 };
160
161 Ok(PayoutSummaryPartialTemplate {
162 payout_summary,
163 stripe_payouts_enabled: user.stripe_payouts_enabled,
164 })
165 }
166
167 /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab).
168 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")]
169 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
170 State(db): State<PgPool>,
171 AuthUser(session_user): AuthUser,
172 ) -> Result<impl IntoResponse> {
173 let contacts = db::transactions::get_seller_contacts(&db, session_user.id).await?;
174 let contact_views: Vec<BuyerContact> = contacts
175 .into_iter()
176 .map(|c| BuyerContact {
177 username: c.username,
178 email: c.email,
179 total_purchases: c.total_purchases,
180 total_spent: helpers::format_revenue(
181 c.total_spent_cents,
182 session_user.settlement_currency,
183 ),
184 last_purchase: c.last_purchase_at.format("%b %-d, %Y").to_string(),
185 })
186 .collect();
187 Ok(BuyerContactsPartialTemplate {
188 contacts: contact_views,
189 })
190 }
191
192 /// Render the HTMX partial for the filtered transactions table.
193 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")]
194 pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
195 State(db): State<PgPool>,
196 AuthUser(session_user): AuthUser,
197 ValidatedQuery(query): ValidatedQuery<super::super::super::TransactionQuery>,
198 ) -> Result<impl IntoResponse> {
199 // Only fetch the direction the user asked for
200 let transactions = match query.r#type.as_deref() {
201 Some("incoming") => {
202 let txs = db::transactions::get_transactions_by_seller(
203 &db,
204 session_user.id,
205 Some(DASHBOARD_TRANSACTION_LIMIT),
206 )
207 .await?;
208 txs.iter().map(Transaction::from_sale).collect()
209 }
210 Some("outgoing") => {
211 let txs = db::transactions::get_transactions_by_buyer(
212 &db,
213 session_user.id,
214 Some(DASHBOARD_TRANSACTION_LIMIT),
215 )
216 .await?;
217 txs.iter().map(Transaction::from_purchase).collect()
218 }
219 _ => {
220 let incoming = db::transactions::get_transactions_by_seller(
221 &db,
222 session_user.id,
223 Some(DASHBOARD_TRANSACTION_LIMIT),
224 )
225 .await?;
226 let outgoing = db::transactions::get_transactions_by_buyer(
227 &db,
228 session_user.id,
229 Some(DASHBOARD_TRANSACTION_LIMIT),
230 )
231 .await?;
232 super::super::super::collect_transactions(&incoming, &outgoing)
233 }
234 };
235
236 Ok(TransactionsTableTemplate { transactions })
237 }
238