Skip to main content

max / makenotwork

7.9 KB · 257 lines History Blame Raw
1 //! Everything tying a user to their Stripe account: the connect handshake, the
2 //! currency they settle in, and the founder and tax flags that ride along.
3
4 use sqlx::PgPool;
5
6 use crate::db::UserId;
7 use crate::db::models::DbUser;
8 use crate::error::Result;
9
10 /// Update user's Stripe Connect account information after OAuth
11 #[tracing::instrument(skip_all)]
12 pub async fn update_user_stripe_account(
13 pool: &PgPool,
14 user_id: UserId,
15 stripe_account_id: &str,
16 onboarding_complete: bool,
17 payouts_enabled: bool,
18 charges_enabled: bool,
19 ) -> Result<DbUser> {
20 let user = sqlx::query_as::<_, DbUser>(
21 r"
22 UPDATE users
23 SET stripe_account_id = $2,
24 stripe_onboarding_complete = $3,
25 stripe_payouts_enabled = $4,
26 stripe_charges_enabled = $5,
27 updated_at = NOW()
28 WHERE id = $1
29 RETURNING *
30 ",
31 )
32 .bind(user_id)
33 .bind(stripe_account_id)
34 .bind(onboarding_complete)
35 .bind(payouts_enabled)
36 .bind(charges_enabled)
37 .fetch_one(pool)
38 .await?;
39
40 Ok(user)
41 }
42
43 /// Atomically set a user's Stripe Connect account ID, but only if one is not
44 /// already set. Returns `Some(user)` on success, or `None` if another request
45 /// already claimed the slot (race-condition guard).
46 #[tracing::instrument(skip_all)]
47 pub async fn try_set_stripe_account(
48 pool: &PgPool,
49 user_id: UserId,
50 stripe_account_id: &str,
51 ) -> Result<Option<DbUser>> {
52 let user = sqlx::query_as::<_, DbUser>(
53 r"
54 UPDATE users
55 SET stripe_account_id = $2,
56 stripe_onboarding_complete = false,
57 stripe_payouts_enabled = false,
58 stripe_charges_enabled = false,
59 updated_at = NOW()
60 WHERE id = $1 AND (stripe_account_id IS NULL OR stripe_account_id = '')
61 RETURNING *
62 ",
63 )
64 .bind(user_id)
65 .bind(stripe_account_id)
66 .fetch_optional(pool)
67 .await?;
68
69 Ok(user)
70 }
71
72 /// Update user's Stripe status from webhook (finds user by stripe_account_id)
73 #[tracing::instrument(skip_all)]
74 pub async fn update_user_stripe_status(
75 pool: &PgPool,
76 stripe_account_id: &str,
77 onboarding_complete: bool,
78 payouts_enabled: bool,
79 charges_enabled: bool,
80 settlement_currency: Option<crate::currency::SettlementCurrency>,
81 ) -> Result<Option<DbUser>> {
82 // COALESCE, not a plain assignment: `None` means Stripe told us nothing
83 // usable this time (too early in onboarding, or a currency outside our six),
84 // and overwriting a known currency with USD on that signal would redenominate
85 // every price the creator has set.
86 let user = sqlx::query_as::<_, DbUser>(
87 r"
88 UPDATE users
89 SET stripe_onboarding_complete = $2,
90 stripe_payouts_enabled = $3,
91 stripe_charges_enabled = $4,
92 settlement_currency = COALESCE($5, settlement_currency),
93 updated_at = NOW()
94 WHERE stripe_account_id = $1
95 RETURNING *
96 ",
97 )
98 .bind(stripe_account_id)
99 .bind(onboarding_complete)
100 .bind(payouts_enabled)
101 .bind(charges_enabled)
102 .bind(settlement_currency)
103 .fetch_optional(pool)
104 .await?;
105
106 Ok(user)
107 }
108
109 /// The settlement currency currently stored for a connected account, if any.
110 ///
111 /// Read before a webhook write so a *change* can be distinguished from a
112 /// restatement of the same value. Stripe re-sends `account.updated` constantly,
113 /// so alerting on every write would be noise; alerting on none of them would
114 /// leave a creator's prices silently meaning different money.
115 #[tracing::instrument(skip_all)]
116 pub async fn get_settlement_currency_by_stripe_account(
117 pool: &PgPool,
118 stripe_account_id: &str,
119 ) -> Result<Option<crate::currency::SettlementCurrency>> {
120 let row: Option<(crate::currency::SettlementCurrency,)> =
121 sqlx::query_as("SELECT settlement_currency FROM users WHERE stripe_account_id = $1")
122 .bind(stripe_account_id)
123 .fetch_optional(pool)
124 .await?;
125 Ok(row.map(|(c,)| c))
126 }
127
128 /// The account behind a Stripe Connect account id.
129 #[tracing::instrument(skip_all)]
130 pub async fn get_user_id_by_stripe_account(
131 pool: &PgPool,
132 stripe_account_id: &str,
133 ) -> Result<Option<UserId>> {
134 let id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE stripe_account_id = $1")
135 .bind(stripe_account_id)
136 .fetch_optional(pool)
137 .await?;
138 Ok(id)
139 }
140
141 /// Store a buyer's cross-currency conversion preference.
142 ///
143 /// A preference, not a lock: the checkout form decides each purchase, and this
144 /// only changes what that form comes back pre-selected with next time.
145 #[tracing::instrument(skip_all)]
146 pub async fn set_conversion_preference(
147 pool: &PgPool,
148 user_id: UserId,
149 conversion: crate::currency::ConversionChoice,
150 ) -> Result<()> {
151 sqlx::query("UPDATE users SET conversion_preference = $2, updated_at = NOW() WHERE id = $1")
152 .bind(user_id)
153 .bind(conversion)
154 .execute(pool)
155 .await?;
156 Ok(())
157 }
158
159 /// Mark a user as a founder. Called when they start a creator-tier
160 /// subscription while the founder pricing window is open. Sticky; never
161 /// reset, even on cancellation. Subsequent re-subscriptions during the
162 /// window keep their founder status. After the window closes, eligibility
163 /// is determined by `founder_locked_at` (stamped only for users with an
164 /// active subscription at the close-time snapshot).
165 ///
166 /// **DIY exclusion**: DIY-tier accounts are not full members and must not
167 /// qualify for founder pricing. This function does not enforce that, it sets
168 /// `is_founder` unconditionally, so the exclusion is a caller obligation: only
169 /// call this from creator-tier (Basic/SmallFiles/BigFiles/Everything) checkout
170 /// paths. When DIY ships, its checkout path must NOT invoke this.
171 #[tracing::instrument(skip_all)]
172 pub async fn mark_user_as_founder(pool: &PgPool, user_id: UserId) -> Result<()> {
173 sqlx::query(
174 r"
175 UPDATE users
176 SET is_founder = TRUE,
177 updated_at = NOW()
178 WHERE id = $1 AND is_founder = FALSE
179 ",
180 )
181 .bind(user_id)
182 .execute(pool)
183 .await?;
184 Ok(())
185 }
186
187 /// Close the founder pricing window by stamping `founder_locked_at` on every
188 /// user who is currently flagged `is_founder` AND has an active creator-tier
189 /// subscription. Returns the number of users locked in. Idempotent: skips
190 /// any user already locked. Intended to be called once from an admin tool
191 /// at the moment the founder window closes.
192 #[tracing::instrument(skip_all)]
193 pub async fn lock_in_founders_with_active_subscriptions(pool: &PgPool) -> Result<u64> {
194 let result = sqlx::query(
195 r"
196 UPDATE users u
197 SET founder_locked_at = NOW(),
198 updated_at = NOW()
199 WHERE u.is_founder = TRUE
200 AND u.founder_locked_at IS NULL
201 AND EXISTS (
202 SELECT 1 FROM creator_subscriptions s
203 WHERE s.user_id = u.id
204 AND s.status = 'active'
205 )
206 ",
207 )
208 .execute(pool)
209 .await?;
210 Ok(result.rows_affected())
211 }
212
213 /// Update a user's Stripe Tax toggle.
214 #[tracing::instrument(skip_all)]
215 pub async fn update_stripe_tax_enabled(
216 pool: &PgPool,
217 user_id: UserId,
218 enabled: bool,
219 ) -> Result<()> {
220 sqlx::query(
221 r"
222 UPDATE users
223 SET stripe_tax_enabled = $2,
224 updated_at = NOW()
225 WHERE id = $1
226 ",
227 )
228 .bind(user_id)
229 .bind(enabled)
230 .execute(pool)
231 .await?;
232
233 Ok(())
234 }
235
236 /// Disconnect a user's Stripe account
237 #[tracing::instrument(skip_all)]
238 pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<DbUser> {
239 let user = sqlx::query_as::<_, DbUser>(
240 r"
241 UPDATE users
242 SET stripe_account_id = NULL,
243 stripe_onboarding_complete = false,
244 stripe_payouts_enabled = false,
245 stripe_charges_enabled = false,
246 updated_at = NOW()
247 WHERE id = $1
248 RETURNING *
249 ",
250 )
251 .bind(user_id)
252 .fetch_one(pool)
253 .await?;
254
255 Ok(user)
256 }
257