Skip to main content

max / makenotwork

8.4 KB · 242 lines History Blame Raw
1 //! Tip CRUD operations.
2
3 use sqlx::PgPool;
4
5 use super::id_types::{ProjectId, TipId, UserId};
6 use super::models::{DbTip, DbTipWithUser};
7 use super::validated_types::Cents;
8 use crate::error::Result;
9
10 /// Create a pending tip record before redirecting to Stripe Checkout.
11 #[tracing::instrument(skip(pool))]
12 #[allow(
13 clippy::too_many_arguments,
14 reason = "one flat argument list per tip column; a params struct for a \
15 single call site would be indirection without a reader"
16 )]
17 pub async fn create_tip(
18 pool: &PgPool,
19 tipper_id: UserId,
20 recipient_id: UserId,
21 project_id: Option<ProjectId>,
22 amount_cents: i32,
23 message: Option<&str>,
24 stripe_checkout_session_id: &str,
25 currency: crate::currency::SettlementCurrency,
26 ) -> Result<DbTip> {
27 // Defense in depth: the route handler enforces a $1 minimum and the column
28 // carries CHECK (amount_cents > 0), but guard here too so any future caller
29 // gets a clean validation error instead of a raw constraint violation.
30 if amount_cents <= 0 {
31 return Err(crate::error::AppError::validation(
32 "Tip amount must be positive",
33 ));
34 }
35 let tip = sqlx::query_as!(
36 DbTip,
37 r#"
38 INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id, currency)
39 VALUES ($1, $2, $3, $4, $5, $6, $7)
40 RETURNING
41 id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
42 project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
43 status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
44 stripe_transfer_group,
45 created_at AS "created_at: chrono::DateTime<chrono::Utc>",
46 completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
47 currency AS "currency: crate::currency::SettlementCurrency"
48 "#,
49 tipper_id as UserId,
50 recipient_id as UserId,
51 project_id as Option<ProjectId>,
52 amount_cents,
53 message,
54 stripe_checkout_session_id,
55 currency.code(),
56 )
57 .fetch_one(pool)
58 .await?;
59
60 Ok(tip)
61 }
62
63 /// Mark a tip as completed after Stripe confirms payment.
64 /// Returns `Some(tip)` if updated, `None` if already completed (idempotent).
65 #[tracing::instrument(skip(executor))]
66 pub async fn complete_tip<'e>(
67 executor: impl sqlx::PgExecutor<'e>,
68 stripe_checkout_session_id: &str,
69 stripe_payment_intent_id: Option<&str>,
70 ) -> Result<Option<DbTip>> {
71 let tip = sqlx::query_as!(
72 DbTip,
73 r#"
74 UPDATE tips
75 SET status = 'completed',
76 stripe_payment_intent_id = $2,
77 completed_at = NOW()
78 WHERE stripe_checkout_session_id = $1
79 AND status = 'pending'
80 RETURNING
81 id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
82 project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
83 status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
84 stripe_transfer_group,
85 created_at AS "created_at: chrono::DateTime<chrono::Utc>",
86 completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
87 currency AS "currency: crate::currency::SettlementCurrency"
88 "#,
89 stripe_checkout_session_id,
90 stripe_payment_intent_id,
91 )
92 .fetch_optional(executor)
93 .await?;
94
95 Ok(tip)
96 }
97
98 /// Fetch a tip by its Stripe checkout session id, regardless of status.
99 ///
100 /// Used for webhook crash recovery: when `complete_tip` returns `None` (the tip
101 /// was already flipped to completed by an earlier delivery), the handler re-reads
102 /// the tip here to re-run the idempotent split write, in case the first delivery
103 /// crashed after completing the tip but before recording splits.
104 #[tracing::instrument(skip(pool))]
105 pub async fn get_tip_by_session(
106 pool: &PgPool,
107 stripe_checkout_session_id: &str,
108 ) -> Result<Option<DbTip>> {
109 let tip = sqlx::query_as!(
110 DbTip,
111 r#"
112 SELECT
113 id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
114 project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
115 status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
116 stripe_transfer_group,
117 created_at AS "created_at: chrono::DateTime<chrono::Utc>",
118 completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
119 currency AS "currency: crate::currency::SettlementCurrency"
120 FROM tips
121 WHERE stripe_checkout_session_id = $1
122 "#,
123 stripe_checkout_session_id,
124 )
125 .fetch_optional(pool)
126 .await?;
127
128 Ok(tip)
129 }
130
131 /// Get tips received by a creator, most recent first.
132 #[tracing::instrument(skip(pool))]
133 pub async fn get_tips_received(
134 pool: &PgPool,
135 recipient_id: UserId,
136 limit: i64,
137 offset: i64,
138 ) -> Result<Vec<DbTipWithUser>> {
139 let tips = sqlx::query_as!(
140 DbTipWithUser,
141 r#"
142 SELECT t.id AS "id: TipId", t.tipper_id AS "tipper_id: UserId", t.recipient_id AS "recipient_id: UserId",
143 t.project_id AS "project_id: ProjectId", t.amount_cents AS "amount_cents: Cents",
144 t.message, t.status AS "status: super::TransactionStatus",
145 t.created_at AS "created_at: chrono::DateTime<chrono::Utc>",
146 t.completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
147 u.username AS tipper_username, u.display_name AS tipper_display_name
148 FROM tips t
149 JOIN users u ON u.id = t.tipper_id
150 WHERE t.recipient_id = $1 AND t.status = 'completed'
151 ORDER BY t.created_at DESC
152 LIMIT $2 OFFSET $3
153 "#,
154 recipient_id as UserId,
155 limit,
156 offset,
157 )
158 .fetch_all(pool)
159 .await?;
160
161 Ok(tips)
162 }
163
164 /// Total tip revenue received by a creator (completed tips only).
165 #[tracing::instrument(skip(pool))]
166 pub async fn total_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
167 let total = sqlx::query_scalar!(
168 r#"SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "total!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#,
169 recipient_id as UserId,
170 )
171 .fetch_one(pool)
172 .await?;
173
174 Ok(total)
175 }
176
177 /// Count of completed tips received.
178 #[tracing::instrument(skip(pool))]
179 pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
180 let count = sqlx::query_scalar!(
181 r#"SELECT COUNT(*) AS "count!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#,
182 recipient_id as UserId,
183 )
184 .fetch_one(pool)
185 .await?;
186
187 Ok(count)
188 }
189
190 /// Mark a tip as refunded by payment intent ID.
191 /// Returns true if a tip was refunded, false if not found (idempotent).
192 #[tracing::instrument(skip(pool))]
193 pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> {
194 let result = sqlx::query!(
195 r#"
196 UPDATE tips
197 SET status = 'refunded'
198 WHERE stripe_payment_intent_id = $1 AND status = 'completed'
199 "#,
200 payment_intent_id,
201 )
202 .execute(pool)
203 .await?;
204
205 Ok(result.rows_affected() > 0)
206 }
207
208 /// Get tips sent by a user, most recent first.
209 #[allow(dead_code)]
210 #[tracing::instrument(skip(pool))]
211 pub async fn get_tips_sent(
212 pool: &PgPool,
213 tipper_id: UserId,
214 limit: i64,
215 offset: i64,
216 ) -> Result<Vec<DbTip>> {
217 let tips = sqlx::query_as!(
218 DbTip,
219 r#"
220 SELECT
221 id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
222 project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
223 status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
224 stripe_transfer_group,
225 created_at AS "created_at: chrono::DateTime<chrono::Utc>",
226 completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
227 currency AS "currency: crate::currency::SettlementCurrency"
228 FROM tips
229 WHERE tipper_id = $1 AND status = 'completed'
230 ORDER BY created_at DESC
231 LIMIT $2 OFFSET $3
232 "#,
233 tipper_id as UserId,
234 limit,
235 offset,
236 )
237 .fetch_all(pool)
238 .await?;
239
240 Ok(tips)
241 }
242