Skip to main content

max / makenotwork

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