Skip to main content

max / makenotwork

6.9 KB · 190 lines History Blame Raw
1 //! Payment-related dashboard tab handlers.
2
3 use axum::extract::{Query, State};
4 use axum::response::IntoResponse;
5
6 use crate::{
7 Billing,
8 auth::AuthUser,
9 constants::DASHBOARD_TRANSACTION_LIMIT,
10 db,
11 error::Result,
12 helpers,
13 templates::{
14 BuyerContactsPartialTemplate, PayoutSummaryPartialTemplate, TransactionsTableTemplate,
15 UserPaymentsTabTemplate,
16 },
17 types::{BuyerContact, PayoutSummary, TipReceived, Transaction, User},
18 };
19 use sqlx::PgPool;
20
21 /// Render the HTMX partial for the dashboard payments tab.
22 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")]
23 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
24 State(db): State<PgPool>,
25 AuthUser(session_user): AuthUser,
26 ) -> Result<impl IntoResponse> {
27 let db_user = db::users::get_user_by_id(&db, session_user.id)
28 .await?
29 .ok_or(crate::error::AppError::NotFound)?;
30
31 let user = User::from(&db_user);
32
33 let incoming_txs = db::transactions::get_transactions_by_seller(
34 &db,
35 session_user.id,
36 Some(DASHBOARD_TRANSACTION_LIMIT),
37 )
38 .await?;
39 let outgoing_txs = db::transactions::get_transactions_by_buyer(
40 &db,
41 session_user.id,
42 Some(DASHBOARD_TRANSACTION_LIMIT),
43 )
44 .await?;
45 let transactions = super::super::super::collect_transactions(&incoming_txs, &outgoing_txs);
46
47 let db_tips = db::tips::get_tips_received(&db, session_user.id, 20, 0).await?;
48 let tips_total_cents = db::tips::total_tips_received(&db, session_user.id).await?;
49 let tips_count = db::tips::count_tips_received(&db, session_user.id).await?;
50 let tips_received: Vec<TipReceived> = db_tips
51 .iter()
52 .map(|t| TipReceived {
53 date: t.created_at.format("%Y-%m-%d").to_string(),
54 tipper_name: t
55 .tipper_display_name
56 .clone()
57 .unwrap_or_else(|| t.tipper_username.clone()),
58 amount: helpers::format_price(t.amount_cents),
59 message: t.message.clone(),
60 })
61 .collect();
62
63 // Revenue splits
64 let splits_incoming_cents =
65 db::project_members::total_split_revenue(&db, session_user.id).await?;
66 let splits_incoming_count =
67 db::project_members::count_splits_for_recipient(&db, session_user.id).await?;
68 let splits_outgoing_cents =
69 db::project_members::total_split_obligations(&db, session_user.id).await?;
70
71 Ok(UserPaymentsTabTemplate {
72 user,
73 transactions,
74 tips_received,
75 tips_total: helpers::format_revenue(tips_total_cents),
76 tips_count,
77 splits_incoming_total: helpers::format_revenue(splits_incoming_cents),
78 splits_incoming_count,
79 splits_outgoing_total: helpers::format_revenue(splits_outgoing_cents),
80 can_create_projects: session_user.can_create_projects,
81 })
82 }
83
84 /// Render the HTMX partial for the Stripe payout summary (lazy-loaded in the
85 /// Payments tab). Split out of `dashboard_tab_payments` so the tab renders
86 /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2).
87 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")]
88 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary(
89 State(db): State<PgPool>,
90 State(payments): State<Billing>,
91 AuthUser(session_user): AuthUser,
92 ) -> Result<impl IntoResponse> {
93 let db_user = db::users::get_user_by_id(&db, session_user.id)
94 .await?
95 .ok_or(crate::error::AppError::NotFound)?;
96 let user = User::from(&db_user);
97
98 let payout_summary = match (&payments.stripe, &user.stripe_account_id) {
99 (Some(stripe), Some(account_id)) => match stripe.get_balance(account_id).await {
100 Ok(balance) => Some(PayoutSummary {
101 available: helpers::format_revenue(balance.available_cents),
102 pending: helpers::format_revenue(balance.pending_cents),
103 }),
104 Err(e) => {
105 tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
106 None
107 }
108 },
109 _ => None,
110 };
111
112 Ok(PayoutSummaryPartialTemplate {
113 payout_summary,
114 stripe_payouts_enabled: user.stripe_payouts_enabled,
115 })
116 }
117
118 /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab).
119 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")]
120 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
121 State(db): State<PgPool>,
122 AuthUser(session_user): AuthUser,
123 ) -> Result<impl IntoResponse> {
124 let contacts = db::transactions::get_seller_contacts(&db, session_user.id).await?;
125 let contact_views: Vec<BuyerContact> = contacts
126 .into_iter()
127 .map(|c| BuyerContact {
128 username: c.username,
129 email: c.email,
130 total_purchases: c.total_purchases,
131 total_spent: format!(
132 "${}.{:02}",
133 c.total_spent_cents / 100,
134 c.total_spent_cents.unsigned_abs() % 100
135 ),
136 last_purchase: c.last_purchase_at.format("%b %-d, %Y").to_string(),
137 })
138 .collect();
139 Ok(BuyerContactsPartialTemplate {
140 contacts: contact_views,
141 })
142 }
143
144 /// Render the HTMX partial for the filtered transactions table.
145 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")]
146 pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
147 State(db): State<PgPool>,
148 AuthUser(session_user): AuthUser,
149 Query(query): Query<super::super::super::TransactionQuery>,
150 ) -> Result<impl IntoResponse> {
151 // Only fetch the direction the user asked for
152 let transactions = match query.r#type.as_deref() {
153 Some("incoming") => {
154 let txs = db::transactions::get_transactions_by_seller(
155 &db,
156 session_user.id,
157 Some(DASHBOARD_TRANSACTION_LIMIT),
158 )
159 .await?;
160 txs.iter().map(Transaction::from_sale).collect()
161 }
162 Some("outgoing") => {
163 let txs = db::transactions::get_transactions_by_buyer(
164 &db,
165 session_user.id,
166 Some(DASHBOARD_TRANSACTION_LIMIT),
167 )
168 .await?;
169 txs.iter().map(Transaction::from_purchase).collect()
170 }
171 _ => {
172 let incoming = db::transactions::get_transactions_by_seller(
173 &db,
174 session_user.id,
175 Some(DASHBOARD_TRANSACTION_LIMIT),
176 )
177 .await?;
178 let outgoing = db::transactions::get_transactions_by_buyer(
179 &db,
180 session_user.id,
181 Some(DASHBOARD_TRANSACTION_LIMIT),
182 )
183 .await?;
184 super::super::super::collect_transactions(&incoming, &outgoing)
185 }
186 };
187
188 Ok(TransactionsTableTemplate { transactions })
189 }
190