Skip to main content

max / makenotwork

19.5 KB · 460 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
19 use super::StripeClient;
20 use crate::currency::SettlementCurrency;
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::from(account))
86 }
87
88 /// Create a Product + monthly recurring Price on a connected account.
89 #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")]
90 pub async fn create_subscription_product_and_price(
91 &self,
92 connected_account_id: &str,
93 tier_name: &str,
94 tier_description: Option<&str>,
95 price_cents: i64,
96 currency: SettlementCurrency,
97 ) -> Result<(String, String)> {
98 if price_cents <= 0 {
99 return Err(AppError::BadRequest("Price must be positive".to_string()));
100 }
101
102 let acct = Self::parse_account_id(connected_account_id)?;
103
104 let mut product_req = CreateProduct::new(tier_name.to_string());
105 if let Some(desc) = tier_description {
106 product_req = product_req.description(desc.to_string());
107 }
108 let product = product_req
109 .customize()
110 .account_id(acct.clone())
111 .send(&self.client)
112 .await
113 .map_err(|e| {
114 tracing::error!(error = ?e, "failed to create Stripe product");
115 AppError::BadRequest("Failed to create subscription product".to_string())
116 })?;
117
118 // The tier's Price is minted in the creator's settlement currency and
119 // never re-denominated afterwards. A creator who later changes their
120 // Stripe currency keeps tiers priced in the old one until they re-price,
121 // which is the honest outcome: the number they typed meant that currency.
122 let price = CreatePrice::new(currency.to_stripe())
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 currency: SettlementCurrency,
371 ) -> Result<String> {
372 let acct = Self::parse_account_id(connected_account_id)?;
373 let key = IdempotencyKey::new(format!("platform-credit-{transaction_id}"))
374 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
375 let metadata = std::collections::HashMap::from([
376 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
377 ("reason".to_string(), "platform_funded_credit".to_string()),
378 ]);
379 // Denominated in the sale's currency, not MNW's. The creator is owed the
380 // amount of a sale that was priced in their currency, so MNW carries any
381 // conversion out of its own balance rather than handing the creator a
382 // number that happens to match in USD.
383 let transfer = CreateTransfer::new(currency.to_stripe(), acct.to_string())
384 .amount(amount_cents)
385 .description("Fan+ credit reimbursement")
386 .metadata(metadata)
387 .customize()
388 .request_strategy(RequestStrategy::Idempotent(key))
389 .send(&self.client)
390 .await
391 .map_err(|e| {
392 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer");
393 AppError::Internal(anyhow::anyhow!("Failed to create transfer"))
394 })?;
395 Ok(transfer.id.to_string())
396 }
397
398 /// Reverse a settled platform-funded credit transfer when its sale is
399 /// refunded, pulling the reimbursed amount back from the connected account
400 /// to MNW so the platform isn't left funding a returned item.
401 ///
402 /// The idempotency key is deterministic
403 /// (`platform-credit-reversal-{transaction_id}`), so a retry after a crash
404 /// or transient failure returns the same reversal rather than clawing back
405 /// twice. `transfer_id` is the id captured when the forward transfer settled.
406 #[tracing::instrument(skip_all, name = "payments::create_platform_credit_reversal")]
407 pub async fn create_platform_credit_reversal(
408 &self,
409 transfer_id: &str,
410 amount_cents: i64,
411 transaction_id: crate::db::TransactionId,
412 ) -> Result<()> {
413 let key = IdempotencyKey::new(format!("platform-credit-reversal-{transaction_id}"))
414 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
415 let metadata = std::collections::HashMap::from([
416 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
417 (
418 "reason".to_string(),
419 "platform_funded_credit_reversal".to_string(),
420 ),
421 ]);
422 CreateIdTransferReversal::new(transfer_id.to_string())
423 .amount(amount_cents)
424 .metadata(metadata)
425 .customize()
426 .request_strategy(RequestStrategy::Idempotent(key))
427 .send(&self.client)
428 .await
429 .map_err(|e| {
430 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to reverse platform credit transfer");
431 AppError::Internal(anyhow::anyhow!("Failed to reverse transfer"))
432 })?;
433 Ok(())
434 }
435 }
436
437 #[cfg(test)]
438 mod tests {
439 use super::*;
440
441 // NOTE: async-stripe's `*Id` types are permissive newtypes, `FromStr`
442 // accepts any non-pathological string without validating the `acct_`/`sub_`
443 // prefix, so there is no error path to assert on normal input. These tests
444 // pin what is actually observable: canonical IDs parse and round-trip, and
445 // both account-id call sites now go through the single `parse_account_id`
446 // (the divergent `parse_account_id_internal` was deleted in Run #14).
447
448 #[test]
449 fn account_id_parses_and_round_trips() {
450 let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap();
451 assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G");
452 }
453
454 #[test]
455 fn subscription_id_parses_and_round_trips() {
456 let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap();
457 assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h");
458 }
459 }
460