Skip to main content

max / makenotwork

4.3 KB · 164 lines History Blame Raw
1 //! Tip CRUD operations.
2
3 use sqlx::PgPool;
4
5 use super::id_types::*;
6 use super::models::*;
7 use crate::error::Result;
8
9 /// Create a pending tip record before redirecting to Stripe Checkout.
10 #[tracing::instrument(skip(pool))]
11 pub async fn create_tip(
12 pool: &PgPool,
13 tipper_id: UserId,
14 recipient_id: UserId,
15 project_id: Option<ProjectId>,
16 amount_cents: i32,
17 message: Option<&str>,
18 stripe_checkout_session_id: &str,
19 ) -> Result<DbTip> {
20 let tip = sqlx::query_as::<_, DbTip>(
21 r#"
22 INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id)
23 VALUES ($1, $2, $3, $4, $5, $6)
24 RETURNING *
25 "#,
26 )
27 .bind(tipper_id)
28 .bind(recipient_id)
29 .bind(project_id)
30 .bind(amount_cents)
31 .bind(message)
32 .bind(stripe_checkout_session_id)
33 .fetch_one(pool)
34 .await?;
35
36 Ok(tip)
37 }
38
39 /// Mark a tip as completed after Stripe confirms payment.
40 /// Returns `Some(tip)` if updated, `None` if already completed (idempotent).
41 #[tracing::instrument(skip(executor))]
42 pub async fn complete_tip(
43 executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
44 stripe_checkout_session_id: &str,
45 stripe_payment_intent_id: &str,
46 ) -> Result<Option<DbTip>> {
47 let tip = sqlx::query_as::<_, DbTip>(
48 r#"
49 UPDATE tips
50 SET status = 'completed',
51 stripe_payment_intent_id = $2,
52 completed_at = NOW()
53 WHERE stripe_checkout_session_id = $1
54 AND status = 'pending'
55 RETURNING *
56 "#,
57 )
58 .bind(stripe_checkout_session_id)
59 .bind(stripe_payment_intent_id)
60 .fetch_optional(executor)
61 .await?;
62
63 Ok(tip)
64 }
65
66 /// Get tips received by a creator, most recent first.
67 #[tracing::instrument(skip(pool))]
68 pub async fn get_tips_received(
69 pool: &PgPool,
70 recipient_id: UserId,
71 limit: i64,
72 offset: i64,
73 ) -> Result<Vec<DbTipWithUser>> {
74 let tips = sqlx::query_as::<_, DbTipWithUser>(
75 r#"
76 SELECT t.id, t.tipper_id, t.recipient_id, t.project_id, t.amount_cents,
77 t.message, t.status, t.created_at, t.completed_at,
78 u.username AS tipper_username, u.display_name AS tipper_display_name
79 FROM tips t
80 JOIN users u ON u.id = t.tipper_id
81 WHERE t.recipient_id = $1 AND t.status = 'completed'
82 ORDER BY t.created_at DESC
83 LIMIT $2 OFFSET $3
84 "#,
85 )
86 .bind(recipient_id)
87 .bind(limit)
88 .bind(offset)
89 .fetch_all(pool)
90 .await?;
91
92 Ok(tips)
93 }
94
95 /// Total tip revenue received by a creator (completed tips only).
96 #[tracing::instrument(skip(pool))]
97 pub async fn total_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
98 let row: (Option<i64>,) = sqlx::query_as(
99 "SELECT SUM(amount_cents)::BIGINT FROM tips WHERE recipient_id = $1 AND status = 'completed'",
100 )
101 .bind(recipient_id)
102 .fetch_one(pool)
103 .await?;
104
105 Ok(row.0.unwrap_or(0))
106 }
107
108 /// Count of completed tips received.
109 #[tracing::instrument(skip(pool))]
110 pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
111 let row: (i64,) = sqlx::query_as(
112 "SELECT COUNT(*) FROM tips WHERE recipient_id = $1 AND status = 'completed'",
113 )
114 .bind(recipient_id)
115 .fetch_one(pool)
116 .await?;
117
118 Ok(row.0)
119 }
120
121 /// Mark a tip as refunded by payment intent ID.
122 /// Returns true if a tip was refunded, false if not found (idempotent).
123 #[tracing::instrument(skip(pool))]
124 pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> {
125 let result = sqlx::query(
126 r#"
127 UPDATE tips
128 SET status = 'refunded'
129 WHERE stripe_payment_intent_id = $1 AND status = 'completed'
130 "#,
131 )
132 .bind(payment_intent_id)
133 .execute(pool)
134 .await?;
135
136 Ok(result.rows_affected() > 0)
137 }
138
139 /// Get tips sent by a user, most recent first.
140 #[allow(dead_code)]
141 #[tracing::instrument(skip(pool))]
142 pub async fn get_tips_sent(
143 pool: &PgPool,
144 tipper_id: UserId,
145 limit: i64,
146 offset: i64,
147 ) -> Result<Vec<DbTip>> {
148 let tips = sqlx::query_as::<_, DbTip>(
149 r#"
150 SELECT * FROM tips
151 WHERE tipper_id = $1 AND status = 'completed'
152 ORDER BY created_at DESC
153 LIMIT $2 OFFSET $3
154 "#,
155 )
156 .bind(tipper_id)
157 .bind(limit)
158 .bind(offset)
159 .fetch_all(pool)
160 .await?;
161
162 Ok(tips)
163 }
164