Skip to main content

max / makenotwork

13.4 KB · 340 lines History Blame Raw
1 //! Connected account operations: onboarding, balance, product/price creation,
2 //! subscription lifecycle, refunds, and billing portal.
3
4 use stripe::StripeRequest;
5 use stripe_billing::subscription::{
6 CancelSubscription, ResumeSubscription,
7 UpdateSubscription,
8 UpdateSubscriptionPauseCollection, UpdateSubscriptionPauseCollectionBehavior,
9 };
10 use stripe_billing::billing_portal_session::CreateBillingPortalSession;
11 use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount};
12 use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType};
13 use stripe_core::balance::RetrieveForMyAccountBalance;
14 use stripe_core::refund::CreateRefund;
15 use stripe_product::product::CreateProduct;
16 use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval};
17 use stripe_types::Currency;
18
19 use crate::error::{AppError, Result};
20 use super::StripeClient;
21
22 fn parse_subscription_id(stripe_sub_id: &str) -> Result<stripe_shared::SubscriptionId> {
23 stripe_sub_id.parse().map_err(|e| {
24 AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
25 })
26 }
27
28 impl StripeClient {
29 /// Create a Stripe Standard connected account for a creator.
30 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
31 pub async fn create_connect_account(&self, email: &str) -> Result<String> {
32 let account = CreateAccount::new()
33 .type_(CreateAccountType::Standard)
34 .email(email.to_string())
35 .send(&self.client)
36 .await
37 .map_err(|e| {
38 tracing::error!(error = ?e, "failed to create Stripe connected account");
39 AppError::BadRequest("Failed to create Stripe account".to_string())
40 })?;
41 Ok(account.id.to_string())
42 }
43
44 /// Create an Account Link for Stripe Connect onboarding.
45 #[tracing::instrument(skip_all, name = "payments::create_account_link")]
46 pub async fn create_account_link(
47 &self,
48 account_id: &str,
49 return_url: &str,
50 refresh_url: &str,
51 ) -> Result<String> {
52 // CreateAccountLink takes the account id as a plain String, not AccountId.
53 let link = CreateAccountLink::new(account_id.to_string(), CreateAccountLinkType::AccountOnboarding)
54 .return_url(return_url.to_string())
55 .refresh_url(refresh_url.to_string())
56 .send(&self.client)
57 .await
58 .map_err(|e| {
59 tracing::error!(error = ?e, "failed to create Stripe account link");
60 AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
61 })?;
62 Ok(link.url)
63 }
64
65 /// Fetch a Stripe Connect account by ID.
66 #[tracing::instrument(skip_all, name = "payments::fetch_account")]
67 pub async fn fetch_account(&self, account_id: &str) -> Result<super::AccountUpdate> {
68 let account_id = Self::parse_account_id(account_id)?;
69 let account = RetrieveAccount::new(account_id)
70 .send(&self.client)
71 .await
72 .map_err(|e| {
73 tracing::error!(error = ?e, "failed to fetch Stripe account");
74 AppError::BadRequest("Failed to fetch Stripe account".to_string())
75 })?;
76
77 Ok(super::AccountUpdate {
78 account_id: account.id.to_string(),
79 charges_enabled: account.charges_enabled.unwrap_or(false),
80 payouts_enabled: account.payouts_enabled.unwrap_or(false),
81 details_submitted: account.details_submitted.unwrap_or(false),
82 })
83 }
84
85 /// Create a Product + monthly recurring Price on a connected account.
86 #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")]
87 pub async fn create_subscription_product_and_price(
88 &self,
89 connected_account_id: &str,
90 tier_name: &str,
91 tier_description: Option<&str>,
92 price_cents: i64,
93 ) -> Result<(String, String)> {
94 if price_cents <= 0 {
95 return Err(AppError::BadRequest("Price must be positive".to_string()));
96 }
97
98 let acct = Self::parse_account_id(connected_account_id)?;
99
100 let mut product_req = CreateProduct::new(tier_name.to_string());
101 if let Some(desc) = tier_description {
102 product_req = product_req.description(desc.to_string());
103 }
104 let product = product_req
105 .customize()
106 .account_id(acct.clone())
107 .send(&self.client)
108 .await
109 .map_err(|e| {
110 tracing::error!(error = ?e, "failed to create Stripe product");
111 AppError::BadRequest("Failed to create subscription product".to_string())
112 })?;
113
114 let price = CreatePrice::new(Currency::USD)
115 .product(product.id.to_string())
116 .unit_amount(price_cents)
117 .recurring(CreatePriceRecurring::new(CreatePriceRecurringInterval::Month))
118 .customize()
119 .account_id(acct)
120 .send(&self.client)
121 .await
122 .map_err(|e| {
123 tracing::error!(error = ?e, "failed to create Stripe price");
124 AppError::BadRequest("Failed to create subscription price".to_string())
125 })?;
126
127 Ok((product.id.to_string(), price.id.to_string()))
128 }
129
130 /// Retrieve the balance for a connected account.
131 #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")]
132 pub async fn get_connected_account_balance(&self, account_id: &str) -> Result<stripe_core::Balance> {
133 let acct = Self::parse_account_id(account_id)?;
134 RetrieveForMyAccountBalance::new()
135 .customize()
136 .account_id(acct)
137 .send(&self.client)
138 .await
139 .map_err(|e| {
140 tracing::error!(error = ?e, "failed to fetch Stripe balance");
141 AppError::BadRequest("Failed to fetch Stripe balance".to_string())
142 })
143 }
144
145 /// Pause subscription collection (void invoices) on a connected account.
146 #[tracing::instrument(skip_all, name = "payments::pause_subscription")]
147 pub async fn pause_subscription(
148 &self,
149 stripe_sub_id: &str,
150 connected_account_id: &str,
151 ) -> Result<()> {
152 let acct = Self::parse_account_id(connected_account_id)?;
153 let sub_id = parse_subscription_id(stripe_sub_id)?;
154
155 UpdateSubscription::new(sub_id)
156 .pause_collection(UpdateSubscriptionPauseCollection::new(
157 UpdateSubscriptionPauseCollectionBehavior::Void,
158 ))
159 .customize()
160 .account_id(acct)
161 .send(&self.client)
162 .await
163 .map_err(|e| {
164 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription");
165 AppError::Internal(anyhow::anyhow!("Failed to pause subscription"))
166 })?;
167
168 Ok(())
169 }
170
171 /// Resume a paused subscription on a connected account.
172 ///
173 /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift
174 /// a pause; the legacy "clear `pause_collection`" trick is no longer needed.
175 #[tracing::instrument(skip_all, name = "payments::resume_subscription")]
176 pub async fn resume_subscription(
177 &self,
178 stripe_sub_id: &str,
179 connected_account_id: &str,
180 ) -> Result<()> {
181 let acct = Self::parse_account_id(connected_account_id)?;
182 let sub_id = parse_subscription_id(stripe_sub_id)?;
183
184 ResumeSubscription::new(sub_id)
185 .customize()
186 .account_id(acct)
187 .send(&self.client)
188 .await
189 .map_err(|e| {
190 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription");
191 AppError::Internal(anyhow::anyhow!("Failed to resume subscription"))
192 })?;
193
194 Ok(())
195 }
196
197 /// Cancel a subscription on a connected account (permanent).
198 #[tracing::instrument(skip_all, name = "payments::cancel_subscription")]
199 pub async fn cancel_subscription(
200 &self,
201 stripe_sub_id: &str,
202 connected_account_id: &str,
203 ) -> Result<()> {
204 let acct = Self::parse_account_id(connected_account_id)?;
205 let sub_id = parse_subscription_id(stripe_sub_id)?;
206
207 CancelSubscription::new(sub_id)
208 .customize()
209 .account_id(acct)
210 .send(&self.client)
211 .await
212 .map_err(|e| {
213 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription");
214 AppError::Internal(anyhow::anyhow!("Failed to cancel subscription"))
215 })?;
216
217 Ok(())
218 }
219
220 /// Cancel a platform-level subscription (creator tier, Fan+).
221 #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")]
222 pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> {
223 let sub_id = parse_subscription_id(stripe_sub_id)?;
224 CancelSubscription::new(sub_id)
225 .send(&self.client)
226 .await
227 .map_err(|e| {
228 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription");
229 AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription"))
230 })?;
231 Ok(())
232 }
233
234 /// Set or clear `cancel_at_period_end` on a platform-level subscription.
235 #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")]
236 pub async fn set_platform_cancel_at_period_end(
237 &self,
238 stripe_sub_id: &str,
239 cancel: bool,
240 ) -> Result<()> {
241 let sub_id = parse_subscription_id(stripe_sub_id)?;
242 UpdateSubscription::new(sub_id)
243 .cancel_at_period_end(cancel)
244 .send(&self.client)
245 .await
246 .map_err(|e| {
247 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end");
248 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
249 })?;
250 Ok(())
251 }
252
253 /// Set or clear `cancel_at_period_end` on a connected-account subscription.
254 #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")]
255 pub async fn set_cancel_at_period_end(
256 &self,
257 stripe_sub_id: &str,
258 connected_account_id: &str,
259 cancel: bool,
260 ) -> Result<()> {
261 let acct = Self::parse_account_id(connected_account_id)?;
262 let sub_id = parse_subscription_id(stripe_sub_id)?;
263 UpdateSubscription::new(sub_id)
264 .cancel_at_period_end(cancel)
265 .customize()
266 .account_id(acct)
267 .send(&self.client)
268 .await
269 .map_err(|e| {
270 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set cancel_at_period_end");
271 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
272 })?;
273 Ok(())
274 }
275
276 /// Create a Stripe Billing Portal session for a customer.
277 #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")]
278 pub async fn create_billing_portal_session(
279 &self,
280 stripe_customer_id: &str,
281 return_url: &str,
282 ) -> Result<String> {
283 let session = CreateBillingPortalSession::new()
284 .customer(stripe_customer_id.to_string())
285 .return_url(return_url.to_string())
286 .send(&self.client)
287 .await
288 .map_err(|e| {
289 tracing::error!(error = ?e, "failed to create billing portal session");
290 AppError::Internal(anyhow::anyhow!("Failed to create billing portal session"))
291 })?;
292 Ok(session.url)
293 }
294
295 /// Issue a full refund for a payment on a connected account.
296 #[tracing::instrument(skip_all, name = "payments::create_refund")]
297 pub async fn create_refund(
298 &self,
299 payment_intent_id: &str,
300 connected_account_id: &str,
301 ) -> Result<()> {
302 let acct = Self::parse_account_id(connected_account_id)?;
303 CreateRefund::new()
304 .payment_intent(payment_intent_id.to_string())
305 .customize()
306 .account_id(acct)
307 .send(&self.client)
308 .await
309 .map_err(|e| {
310 tracing::error!(payment_intent_id = %payment_intent_id, error = ?e, "failed to create Stripe refund");
311 AppError::Internal(anyhow::anyhow!("Failed to create refund"))
312 })?;
313 Ok(())
314 }
315 }
316
317 #[cfg(test)]
318 mod tests {
319 use super::*;
320
321 // NOTE: async-stripe's `*Id` types are permissive newtypes — `FromStr`
322 // accepts any non-pathological string without validating the `acct_`/`sub_`
323 // prefix, so there is no error path to assert on normal input. These tests
324 // pin what is actually observable: canonical IDs parse and round-trip, and
325 // both account-id call sites now go through the single `parse_account_id`
326 // (the divergent `parse_account_id_internal` was deleted in Run #14).
327
328 #[test]
329 fn account_id_parses_and_round_trips() {
330 let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap();
331 assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G");
332 }
333
334 #[test]
335 fn subscription_id_parses_and_round_trips() {
336 let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap();
337 assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h");
338 }
339 }
340