Skip to main content

max / makenotwork

19.1 KB · 455 lines History Blame Raw
1 //! Connected account operations: onboarding, balance, product/price creation,
2 //! subscription lifecycle, refunds, and billing portal.
3
4 use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
5 use stripe_billing::billing_portal_session::CreateBillingPortalSession;
6 use stripe_billing::subscription::{
7 CancelSubscription, ResumeSubscription, UpdateSubscription, UpdateSubscriptionPauseCollection,
8 UpdateSubscriptionPauseCollectionBehavior,
9 };
10 use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount};
11 use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType};
12 use stripe_connect::transfer::CreateTransfer;
13 use stripe_connect::transfer_reversal::CreateIdTransferReversal;
14 use stripe_core::balance::RetrieveForMyAccountBalance;
15 use stripe_core::refund::CreateRefund;
16 use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval};
17 use stripe_product::product::CreateProduct;
18 use stripe_types::Currency;
19
20 use super::StripeClient;
21 use crate::db::StripeAccountId;
22 use crate::error::{AppError, Result};
23
24 fn parse_subscription_id(stripe_sub_id: &str) -> Result<stripe_shared::SubscriptionId> {
25 stripe_sub_id.parse().map_err(|e| {
26 AppError::Internal(anyhow::anyhow!(
27 "Invalid Stripe subscription ID '{stripe_sub_id}': {e}"
28 ))
29 })
30 }
31
32 impl StripeClient {
33 /// Create a Stripe Standard connected account for a creator.
34 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
35 pub async fn create_connect_account(&self, email: &str) -> Result<StripeAccountId> {
36 let account = CreateAccount::new()
37 .type_(CreateAccountType::Standard)
38 .email(email.to_string())
39 .send(&self.client)
40 .await
41 .map_err(|e| {
42 tracing::error!(error = ?e, "failed to create Stripe connected account");
43 AppError::BadRequest("Failed to create Stripe account".to_string())
44 })?;
45 // Stripe minted this id; trust its shape rather than re-validating.
46 Ok(StripeAccountId::from_trusted(account.id.to_string()))
47 }
48
49 /// Create an Account Link for Stripe Connect onboarding.
50 #[tracing::instrument(skip_all, name = "payments::create_account_link")]
51 pub async fn create_account_link(
52 &self,
53 account_id: &str,
54 return_url: &str,
55 refresh_url: &str,
56 ) -> Result<String> {
57 // CreateAccountLink takes the account id as a plain String, not AccountId.
58 let link = CreateAccountLink::new(
59 account_id.to_string(),
60 CreateAccountLinkType::AccountOnboarding,
61 )
62 .return_url(return_url.to_string())
63 .refresh_url(refresh_url.to_string())
64 .send(&self.client)
65 .await
66 .map_err(|e| {
67 tracing::error!(error = ?e, "failed to create Stripe account link");
68 AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
69 })?;
70 Ok(link.url)
71 }
72
73 /// Fetch a Stripe Connect account by ID.
74 #[tracing::instrument(skip_all, name = "payments::fetch_account")]
75 pub async fn fetch_account(&self, account_id: &str) -> Result<super::AccountUpdate> {
76 let account_id = Self::parse_account_id(account_id)?;
77 let account = RetrieveAccount::new(account_id)
78 .send(&self.client)
79 .await
80 .map_err(|e| {
81 tracing::error!(error = ?e, "failed to fetch Stripe account");
82 AppError::BadRequest("Failed to fetch Stripe account".to_string())
83 })?;
84
85 Ok(super::AccountUpdate {
86 account_id: account.id.to_string(),
87 charges_enabled: account.charges_enabled.unwrap_or(false),
88 payouts_enabled: account.payouts_enabled.unwrap_or(false),
89 details_submitted: account.details_submitted.unwrap_or(false),
90 })
91 }
92
93 /// Create a Product + monthly recurring Price on a connected account.
94 #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")]
95 pub async fn create_subscription_product_and_price(
96 &self,
97 connected_account_id: &str,
98 tier_name: &str,
99 tier_description: Option<&str>,
100 price_cents: i64,
101 ) -> Result<(String, String)> {
102 if price_cents <= 0 {
103 return Err(AppError::BadRequest("Price must be positive".to_string()));
104 }
105
106 let acct = Self::parse_account_id(connected_account_id)?;
107
108 let mut product_req = CreateProduct::new(tier_name.to_string());
109 if let Some(desc) = tier_description {
110 product_req = product_req.description(desc.to_string());
111 }
112 let product = product_req
113 .customize()
114 .account_id(acct.clone())
115 .send(&self.client)
116 .await
117 .map_err(|e| {
118 tracing::error!(error = ?e, "failed to create Stripe product");
119 AppError::BadRequest("Failed to create subscription product".to_string())
120 })?;
121
122 let price = CreatePrice::new(Currency::USD)
123 .product(product.id.to_string())
124 .unit_amount(price_cents)
125 .recurring(CreatePriceRecurring::new(
126 CreatePriceRecurringInterval::Month,
127 ))
128 .customize()
129 .account_id(acct)
130 .send(&self.client)
131 .await
132 .map_err(|e| {
133 tracing::error!(error = ?e, "failed to create Stripe price");
134 AppError::BadRequest("Failed to create subscription price".to_string())
135 })?;
136
137 Ok((product.id.to_string(), price.id.to_string()))
138 }
139
140 /// Retrieve the balance for a connected account.
141 #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")]
142 pub async fn get_connected_account_balance(
143 &self,
144 account_id: &str,
145 ) -> Result<stripe_core::Balance> {
146 let acct = Self::parse_account_id(account_id)?;
147 RetrieveForMyAccountBalance::new()
148 .customize()
149 .account_id(acct)
150 .send(&self.client)
151 .await
152 .map_err(|e| {
153 tracing::error!(error = ?e, "failed to fetch Stripe balance");
154 AppError::BadRequest("Failed to fetch Stripe balance".to_string())
155 })
156 }
157
158 /// Pause subscription collection (void invoices) on a connected account.
159 #[tracing::instrument(skip_all, name = "payments::pause_subscription")]
160 pub async fn pause_subscription(
161 &self,
162 stripe_sub_id: &str,
163 connected_account_id: &str,
164 ) -> Result<()> {
165 let acct = Self::parse_account_id(connected_account_id)?;
166 let sub_id = parse_subscription_id(stripe_sub_id)?;
167
168 UpdateSubscription::new(sub_id)
169 .pause_collection(UpdateSubscriptionPauseCollection::new(
170 UpdateSubscriptionPauseCollectionBehavior::Void,
171 ))
172 .customize()
173 .account_id(acct)
174 .send(&self.client)
175 .await
176 .map_err(|e| {
177 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription");
178 AppError::Internal(anyhow::anyhow!("Failed to pause subscription"))
179 })?;
180
181 Ok(())
182 }
183
184 /// Resume a paused subscription on a connected account.
185 ///
186 /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift
187 /// a pause; the legacy "clear `pause_collection`" trick is no longer needed.
188 #[tracing::instrument(skip_all, name = "payments::resume_subscription")]
189 pub async fn resume_subscription(
190 &self,
191 stripe_sub_id: &str,
192 connected_account_id: &str,
193 ) -> Result<()> {
194 let acct = Self::parse_account_id(connected_account_id)?;
195 let sub_id = parse_subscription_id(stripe_sub_id)?;
196
197 ResumeSubscription::new(sub_id)
198 .customize()
199 .account_id(acct)
200 .send(&self.client)
201 .await
202 .map_err(|e| {
203 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription");
204 AppError::Internal(anyhow::anyhow!("Failed to resume subscription"))
205 })?;
206
207 Ok(())
208 }
209
210 /// Cancel a subscription on a connected account (permanent).
211 #[tracing::instrument(skip_all, name = "payments::cancel_subscription")]
212 pub async fn cancel_subscription(
213 &self,
214 stripe_sub_id: &str,
215 connected_account_id: &str,
216 ) -> Result<()> {
217 let acct = Self::parse_account_id(connected_account_id)?;
218 let sub_id = parse_subscription_id(stripe_sub_id)?;
219
220 CancelSubscription::new(sub_id)
221 .customize()
222 .account_id(acct)
223 .send(&self.client)
224 .await
225 .map_err(|e| {
226 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription");
227 AppError::Internal(anyhow::anyhow!("Failed to cancel subscription"))
228 })?;
229
230 Ok(())
231 }
232
233 /// Cancel a platform-level subscription (creator tier, Fan+).
234 #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")]
235 pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> {
236 let sub_id = parse_subscription_id(stripe_sub_id)?;
237 CancelSubscription::new(sub_id)
238 .send(&self.client)
239 .await
240 .map_err(|e| {
241 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription");
242 AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription"))
243 })?;
244 Ok(())
245 }
246
247 /// Set or clear `cancel_at_period_end` on a platform-level subscription.
248 #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")]
249 pub async fn set_platform_cancel_at_period_end(
250 &self,
251 stripe_sub_id: &str,
252 cancel: bool,
253 ) -> Result<()> {
254 let sub_id = parse_subscription_id(stripe_sub_id)?;
255 UpdateSubscription::new(sub_id)
256 .cancel_at_period_end(cancel)
257 .send(&self.client)
258 .await
259 .map_err(|e| {
260 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end");
261 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
262 })?;
263 Ok(())
264 }
265
266 /// Set or clear `cancel_at_period_end` on a connected-account subscription.
267 #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")]
268 pub async fn set_cancel_at_period_end(
269 &self,
270 stripe_sub_id: &str,
271 connected_account_id: &str,
272 cancel: bool,
273 ) -> Result<()> {
274 let acct = Self::parse_account_id(connected_account_id)?;
275 let sub_id = parse_subscription_id(stripe_sub_id)?;
276 UpdateSubscription::new(sub_id)
277 .cancel_at_period_end(cancel)
278 .customize()
279 .account_id(acct)
280 .send(&self.client)
281 .await
282 .map_err(|e| {
283 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set cancel_at_period_end");
284 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
285 })?;
286 Ok(())
287 }
288
289 /// Create a Stripe Billing Portal session for a customer.
290 #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")]
291 pub async fn create_billing_portal_session(
292 &self,
293 stripe_customer_id: &str,
294 return_url: &str,
295 ) -> Result<String> {
296 let session = CreateBillingPortalSession::new()
297 .customer(stripe_customer_id.to_string())
298 .return_url(return_url.to_string())
299 .send(&self.client)
300 .await
301 .map_err(|e| {
302 tracing::error!(error = ?e, "failed to create billing portal session");
303 AppError::Internal(anyhow::anyhow!("Failed to create billing portal session"))
304 })?;
305 Ok(session.url)
306 }
307
308 /// Issue a line-scoped refund for one transaction on a connected account.
309 ///
310 /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe
311 /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook
312 /// marks and revokes exactly that transaction. Cart checkouts put every line
313 /// of an order under ONE PaymentIntent, so a PI-wide refund would silently
314 /// reverse the whole order (Run #2 Payments SERIOUS).
315 #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")]
316 pub async fn create_refund_for_transaction(
317 &self,
318 payment_intent_id: &str,
319 connected_account_id: &str,
320 amount_cents: i64,
321 transaction_id: crate::db::TransactionId,
322 ) -> Result<()> {
323 let acct = Self::parse_account_id(connected_account_id)?;
324 // Deterministic idempotency key (`refund-{transaction_id}`), mirroring the
325 // platform-credit transfer below: a retry after a crash or transient
326 // failure returns the same refund rather than double-debiting the
327 // creator's connected balance. A transaction is refunded in full exactly
328 // once, so keying on its id is the correct dedup scope.
329 let key = IdempotencyKey::new(format!("refund-{transaction_id}"))
330 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
331 let metadata = std::collections::HashMap::from([(
332 "mnw_transaction_id".to_string(),
333 transaction_id.to_string(),
334 )]);
335 CreateRefund::new()
336 .payment_intent(payment_intent_id.to_string())
337 .amount(amount_cents)
338 .metadata(metadata)
339 .customize()
340 .account_id(acct)
341 .request_strategy(RequestStrategy::Idempotent(key))
342 .send(&self.client)
343 .await
344 .map_err(|e| {
345 tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund");
346 AppError::Internal(anyhow::anyhow!("Failed to create refund"))
347 })?;
348 Ok(())
349 }
350
351 /// Reimburse a creator for a platform-funded credit (the Fan+ renewal credit)
352 /// applied to their sale, so they still net the full pre-discount price and the
353 /// "0% platform fee, creators keep everything" promise holds. This is a platform
354 /// -> connected transfer funded from MNW's own balance (the platform absorbs the
355 /// credit, not the creator).
356 ///
357 /// The idempotency key is deterministic (`platform-credit-{transaction_id}`), so a
358 /// retry after a crash or transient failure returns the same transfer rather than
359 /// paying the creator twice.
360 ///
361 /// Returns the created transfer's Stripe id so the settle path can persist
362 /// it, the reversal path ([`create_platform_credit_reversal`]) needs it to
363 /// claw the funds back if the sale is later refunded.
364 #[tracing::instrument(skip_all, name = "payments::create_platform_credit_transfer")]
365 pub async fn create_platform_credit_transfer(
366 &self,
367 connected_account_id: &str,
368 amount_cents: i64,
369 transaction_id: crate::db::TransactionId,
370 ) -> Result<String> {
371 let acct = Self::parse_account_id(connected_account_id)?;
372 let key = IdempotencyKey::new(format!("platform-credit-{transaction_id}"))
373 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
374 let metadata = std::collections::HashMap::from([
375 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
376 ("reason".to_string(), "platform_funded_credit".to_string()),
377 ]);
378 let transfer = CreateTransfer::new(Currency::USD, acct.to_string())
379 .amount(amount_cents)
380 .description("Fan+ credit reimbursement")
381 .metadata(metadata)
382 .customize()
383 .request_strategy(RequestStrategy::Idempotent(key))
384 .send(&self.client)
385 .await
386 .map_err(|e| {
387 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer");
388 AppError::Internal(anyhow::anyhow!("Failed to create transfer"))
389 })?;
390 Ok(transfer.id.to_string())
391 }
392
393 /// Reverse a settled platform-funded credit transfer when its sale is
394 /// refunded, pulling the reimbursed amount back from the connected account
395 /// to MNW so the platform isn't left funding a returned item.
396 ///
397 /// The idempotency key is deterministic
398 /// (`platform-credit-reversal-{transaction_id}`), so a retry after a crash
399 /// or transient failure returns the same reversal rather than clawing back
400 /// twice. `transfer_id` is the id captured when the forward transfer settled.
401 #[tracing::instrument(skip_all, name = "payments::create_platform_credit_reversal")]
402 pub async fn create_platform_credit_reversal(
403 &self,
404 transfer_id: &str,
405 amount_cents: i64,
406 transaction_id: crate::db::TransactionId,
407 ) -> Result<()> {
408 let key = IdempotencyKey::new(format!("platform-credit-reversal-{transaction_id}"))
409 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
410 let metadata = std::collections::HashMap::from([
411 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
412 (
413 "reason".to_string(),
414 "platform_funded_credit_reversal".to_string(),
415 ),
416 ]);
417 CreateIdTransferReversal::new(transfer_id.to_string())
418 .amount(amount_cents)
419 .metadata(metadata)
420 .customize()
421 .request_strategy(RequestStrategy::Idempotent(key))
422 .send(&self.client)
423 .await
424 .map_err(|e| {
425 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to reverse platform credit transfer");
426 AppError::Internal(anyhow::anyhow!("Failed to reverse transfer"))
427 })?;
428 Ok(())
429 }
430 }
431
432 #[cfg(test)]
433 mod tests {
434 use super::*;
435
436 // NOTE: async-stripe's `*Id` types are permissive newtypes, `FromStr`
437 // accepts any non-pathological string without validating the `acct_`/`sub_`
438 // prefix, so there is no error path to assert on normal input. These tests
439 // pin what is actually observable: canonical IDs parse and round-trip, and
440 // both account-id call sites now go through the single `parse_account_id`
441 // (the divergent `parse_account_id_internal` was deleted in Run #14).
442
443 #[test]
444 fn account_id_parses_and_round_trips() {
445 let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap();
446 assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G");
447 }
448
449 #[test]
450 fn subscription_id_parses_and_round_trips() {
451 let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap();
452 assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h");
453 }
454 }
455