Skip to main content

max / makenotwork

31.8 KB · 795 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 /// The `POST /accounts` body for a creator's Standard connected account.
33 ///
34 /// Split from the method that sends it so the request Stripe is handed can be
35 /// read back in a test. Nothing here calls Stripe, and every builder below
36 /// follows the same shape: the method parses ids, sends, and maps the error;
37 /// the function states what goes on the wire.
38 fn connect_account_request(email: &str) -> CreateAccount {
39 CreateAccount::new()
40 .type_(CreateAccountType::Standard)
41 .email(email.to_string())
42 }
43
44 /// The onboarding Account Link body. `CreateAccountLink` takes the account id
45 /// as a plain String, not an `AccountId`.
46 fn account_link_request(
47 account_id: &str,
48 return_url: &str,
49 refresh_url: &str,
50 ) -> CreateAccountLink {
51 CreateAccountLink::new(
52 account_id.to_string(),
53 CreateAccountLinkType::AccountOnboarding,
54 )
55 .return_url(return_url.to_string())
56 .refresh_url(refresh_url.to_string())
57 }
58
59 /// The Product a creator tier is sold as.
60 fn subscription_product_request(tier_name: &str, tier_description: Option<&str>) -> CreateProduct {
61 let req = CreateProduct::new(tier_name.to_string());
62 match tier_description {
63 Some(desc) => req.description(desc.to_string()),
64 None => req,
65 }
66 }
67
68 /// The monthly recurring Price for a creator tier, in the creator's
69 /// settlement currency.
70 fn subscription_price_request(
71 product_id: &str,
72 price_cents: i64,
73 currency: SettlementCurrency,
74 ) -> CreatePrice {
75 CreatePrice::new(currency.to_stripe())
76 .product(product_id.to_string())
77 .unit_amount(price_cents)
78 .recurring(CreatePriceRecurring::new(
79 CreatePriceRecurringInterval::Month,
80 ))
81 }
82
83 /// Pause collection by voiding invoices, rather than cancelling.
84 fn pause_collection_request(sub_id: stripe_shared::SubscriptionId) -> UpdateSubscription {
85 UpdateSubscription::new(sub_id).pause_collection(UpdateSubscriptionPauseCollection::new(
86 UpdateSubscriptionPauseCollectionBehavior::Void,
87 ))
88 }
89
90 /// Set or clear `cancel_at_period_end`. `cancel` is always sent, so clearing
91 /// the flag is a state the request states rather than one it omits.
92 fn cancel_at_period_end_request(
93 sub_id: stripe_shared::SubscriptionId,
94 cancel: bool,
95 ) -> UpdateSubscription {
96 UpdateSubscription::new(sub_id).cancel_at_period_end(cancel)
97 }
98
99 /// The Billing Portal session body.
100 fn billing_portal_request(
101 stripe_customer_id: &str,
102 return_url: &str,
103 ) -> CreateBillingPortalSession {
104 CreateBillingPortalSession::new()
105 .customer(stripe_customer_id.to_string())
106 .return_url(return_url.to_string())
107 }
108
109 /// A line-scoped refund against the order's shared PaymentIntent, tagged with
110 /// the transaction the `refund.created` webhook has to revoke.
111 fn refund_request(
112 payment_intent_id: &str,
113 amount_cents: i64,
114 transaction_id: crate::db::TransactionId,
115 ) -> CreateRefund {
116 let metadata = std::collections::HashMap::from([(
117 "mnw_transaction_id".to_string(),
118 transaction_id.to_string(),
119 )]);
120 CreateRefund::new()
121 .payment_intent(payment_intent_id.to_string())
122 .amount(amount_cents)
123 .metadata(metadata)
124 }
125
126 /// The platform-funded credit reimbursement, denominated in the sale's
127 /// currency rather than MNW's.
128 fn platform_credit_transfer_request(
129 acct: &stripe::AccountId,
130 amount_cents: i64,
131 transaction_id: crate::db::TransactionId,
132 currency: SettlementCurrency,
133 ) -> CreateTransfer {
134 let metadata = std::collections::HashMap::from([
135 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
136 ("reason".to_string(), "platform_funded_credit".to_string()),
137 ]);
138 CreateTransfer::new(currency.to_stripe(), acct.to_string())
139 .amount(amount_cents)
140 .description("Fan+ credit reimbursement")
141 .metadata(metadata)
142 }
143
144 /// Claw a settled platform credit back when its sale is refunded.
145 fn platform_credit_reversal_request(
146 transfer_id: &str,
147 amount_cents: i64,
148 transaction_id: crate::db::TransactionId,
149 ) -> CreateIdTransferReversal {
150 let metadata = std::collections::HashMap::from([
151 ("mnw_transaction_id".to_string(), transaction_id.to_string()),
152 (
153 "reason".to_string(),
154 "platform_funded_credit_reversal".to_string(),
155 ),
156 ]);
157 CreateIdTransferReversal::new(transfer_id.to_string())
158 .amount(amount_cents)
159 .metadata(metadata)
160 }
161
162 /// Deterministic idempotency keys. A retry after a crash or a transient
163 /// failure has to return the same Stripe object rather than debiting or
164 /// paying a second time, and a transaction is refunded, reimbursed and
165 /// reversed at most once each, so its id is the correct dedup scope.
166 fn refund_key(transaction_id: crate::db::TransactionId) -> String {
167 format!("refund-{transaction_id}")
168 }
169
170 fn platform_credit_key(transaction_id: crate::db::TransactionId) -> String {
171 format!("platform-credit-{transaction_id}")
172 }
173
174 fn platform_credit_reversal_key(transaction_id: crate::db::TransactionId) -> String {
175 format!("platform-credit-reversal-{transaction_id}")
176 }
177
178 impl StripeClient {
179 /// Create a Stripe Standard connected account for a creator.
180 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
181 pub async fn create_connect_account(&self, email: &str) -> Result<StripeAccountId> {
182 let account = connect_account_request(email)
183 .send(&self.client)
184 .await
185 .map_err(|e| {
186 tracing::error!(error = ?e, "failed to create Stripe connected account");
187 AppError::BadRequest("Failed to create Stripe account".to_string())
188 })?;
189 // Stripe minted this id; trust its shape rather than re-validating.
190 Ok(StripeAccountId::from_trusted(account.id.to_string()))
191 }
192
193 /// Create an Account Link for Stripe Connect onboarding.
194 #[tracing::instrument(skip_all, name = "payments::create_account_link")]
195 pub async fn create_account_link(
196 &self,
197 account_id: &str,
198 return_url: &str,
199 refresh_url: &str,
200 ) -> Result<String> {
201 let link = account_link_request(account_id, return_url, refresh_url)
202 .send(&self.client)
203 .await
204 .map_err(|e| {
205 tracing::error!(error = ?e, "failed to create Stripe account link");
206 AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
207 })?;
208 Ok(link.url)
209 }
210
211 /// Fetch a Stripe Connect account by ID.
212 #[tracing::instrument(skip_all, name = "payments::fetch_account")]
213 pub async fn fetch_account(&self, account_id: &str) -> Result<super::AccountUpdate> {
214 let account_id = Self::parse_account_id(account_id)?;
215 let account = RetrieveAccount::new(account_id)
216 .send(&self.client)
217 .await
218 .map_err(|e| {
219 tracing::error!(error = ?e, "failed to fetch Stripe account");
220 AppError::BadRequest("Failed to fetch Stripe account".to_string())
221 })?;
222
223 Ok(super::AccountUpdate::from(account))
224 }
225
226 /// Create a Product + monthly recurring Price on a connected account.
227 #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")]
228 pub async fn create_subscription_product_and_price(
229 &self,
230 connected_account_id: &str,
231 tier_name: &str,
232 tier_description: Option<&str>,
233 price_cents: i64,
234 currency: SettlementCurrency,
235 ) -> Result<(String, String)> {
236 if price_cents <= 0 {
237 return Err(AppError::BadRequest("Price must be positive".to_string()));
238 }
239
240 let acct = Self::parse_account_id(connected_account_id)?;
241
242 let product = subscription_product_request(tier_name, tier_description)
243 .customize()
244 .account_id(acct.clone())
245 .send(&self.client)
246 .await
247 .map_err(|e| {
248 tracing::error!(error = ?e, "failed to create Stripe product");
249 AppError::BadRequest("Failed to create subscription product".to_string())
250 })?;
251
252 // The tier's Price is minted in the creator's settlement currency and
253 // never re-denominated afterwards. A creator who later changes their
254 // Stripe currency keeps tiers priced in the old one until they re-price,
255 // which is the honest outcome: the number they typed meant that currency.
256 let price = subscription_price_request(product.id.as_ref(), price_cents, currency)
257 .customize()
258 .account_id(acct)
259 .send(&self.client)
260 .await
261 .map_err(|e| {
262 tracing::error!(error = ?e, "failed to create Stripe price");
263 AppError::BadRequest("Failed to create subscription price".to_string())
264 })?;
265
266 Ok((product.id.to_string(), price.id.to_string()))
267 }
268
269 /// Retrieve the balance for a connected account.
270 #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")]
271 pub async fn get_connected_account_balance(
272 &self,
273 account_id: &str,
274 ) -> Result<stripe_core::Balance> {
275 let acct = Self::parse_account_id(account_id)?;
276 RetrieveForMyAccountBalance::new()
277 .customize()
278 .account_id(acct)
279 .send(&self.client)
280 .await
281 .map_err(|e| {
282 tracing::error!(error = ?e, "failed to fetch Stripe balance");
283 AppError::BadRequest("Failed to fetch Stripe balance".to_string())
284 })
285 }
286
287 /// Pause subscription collection (void invoices) on a connected account.
288 #[tracing::instrument(skip_all, name = "payments::pause_subscription")]
289 pub async fn pause_subscription(
290 &self,
291 stripe_sub_id: &str,
292 connected_account_id: &str,
293 ) -> Result<()> {
294 let acct = Self::parse_account_id(connected_account_id)?;
295 let sub_id = parse_subscription_id(stripe_sub_id)?;
296
297 pause_collection_request(sub_id)
298 .customize()
299 .account_id(acct)
300 .send(&self.client)
301 .await
302 .map_err(|e| {
303 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription");
304 AppError::Internal(anyhow::anyhow!("Failed to pause subscription"))
305 })?;
306
307 Ok(())
308 }
309
310 /// Resume a paused subscription on a connected account.
311 ///
312 /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift
313 /// a pause; the legacy "clear `pause_collection`" trick is no longer needed.
314 #[tracing::instrument(skip_all, name = "payments::resume_subscription")]
315 pub async fn resume_subscription(
316 &self,
317 stripe_sub_id: &str,
318 connected_account_id: &str,
319 ) -> Result<()> {
320 let acct = Self::parse_account_id(connected_account_id)?;
321 let sub_id = parse_subscription_id(stripe_sub_id)?;
322
323 ResumeSubscription::new(sub_id)
324 .customize()
325 .account_id(acct)
326 .send(&self.client)
327 .await
328 .map_err(|e| {
329 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription");
330 AppError::Internal(anyhow::anyhow!("Failed to resume subscription"))
331 })?;
332
333 Ok(())
334 }
335
336 /// Cancel a subscription on a connected account (permanent).
337 #[tracing::instrument(skip_all, name = "payments::cancel_subscription")]
338 pub async fn cancel_subscription(
339 &self,
340 stripe_sub_id: &str,
341 connected_account_id: &str,
342 ) -> Result<()> {
343 let acct = Self::parse_account_id(connected_account_id)?;
344 let sub_id = parse_subscription_id(stripe_sub_id)?;
345
346 CancelSubscription::new(sub_id)
347 .customize()
348 .account_id(acct)
349 .send(&self.client)
350 .await
351 .map_err(|e| {
352 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription");
353 AppError::Internal(anyhow::anyhow!("Failed to cancel subscription"))
354 })?;
355
356 Ok(())
357 }
358
359 /// Cancel a platform-level subscription (creator tier, Fan+).
360 #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")]
361 pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> {
362 let sub_id = parse_subscription_id(stripe_sub_id)?;
363 CancelSubscription::new(sub_id)
364 .send(&self.client)
365 .await
366 .map_err(|e| {
367 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription");
368 AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription"))
369 })?;
370 Ok(())
371 }
372
373 /// Set or clear `cancel_at_period_end` on a platform-level subscription.
374 #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")]
375 pub async fn set_platform_cancel_at_period_end(
376 &self,
377 stripe_sub_id: &str,
378 cancel: bool,
379 ) -> Result<()> {
380 let sub_id = parse_subscription_id(stripe_sub_id)?;
381 cancel_at_period_end_request(sub_id, cancel)
382 .send(&self.client)
383 .await
384 .map_err(|e| {
385 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end");
386 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
387 })?;
388 Ok(())
389 }
390
391 /// Set or clear `cancel_at_period_end` on a connected-account subscription.
392 #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")]
393 pub async fn set_cancel_at_period_end(
394 &self,
395 stripe_sub_id: &str,
396 connected_account_id: &str,
397 cancel: bool,
398 ) -> Result<()> {
399 let acct = Self::parse_account_id(connected_account_id)?;
400 let sub_id = parse_subscription_id(stripe_sub_id)?;
401 cancel_at_period_end_request(sub_id, cancel)
402 .customize()
403 .account_id(acct)
404 .send(&self.client)
405 .await
406 .map_err(|e| {
407 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set cancel_at_period_end");
408 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
409 })?;
410 Ok(())
411 }
412
413 /// Create a Stripe Billing Portal session for a customer.
414 #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")]
415 pub async fn create_billing_portal_session(
416 &self,
417 stripe_customer_id: &str,
418 return_url: &str,
419 ) -> Result<String> {
420 let session = billing_portal_request(stripe_customer_id, return_url)
421 .send(&self.client)
422 .await
423 .map_err(|e| {
424 tracing::error!(error = ?e, "failed to create billing portal session");
425 AppError::Internal(anyhow::anyhow!("Failed to create billing portal session"))
426 })?;
427 Ok(session.url)
428 }
429
430 /// Issue a line-scoped refund for one transaction on a connected account.
431 ///
432 /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe
433 /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook
434 /// marks and revokes exactly that transaction. Cart checkouts put every line
435 /// of an order under ONE PaymentIntent, so a PI-wide refund would silently
436 /// reverse the whole order (Run #2 Payments SERIOUS).
437 #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")]
438 pub async fn create_refund_for_transaction(
439 &self,
440 payment_intent_id: &str,
441 connected_account_id: &str,
442 amount_cents: i64,
443 transaction_id: crate::db::TransactionId,
444 ) -> Result<()> {
445 let acct = Self::parse_account_id(connected_account_id)?;
446 // Deterministic idempotency key (`refund-{transaction_id}`), mirroring the
447 // platform-credit transfer below: a retry after a crash or transient
448 // failure returns the same refund rather than double-debiting the
449 // creator's connected balance. A transaction is refunded in full exactly
450 // once, so keying on its id is the correct dedup scope.
451 let key = IdempotencyKey::new(refund_key(transaction_id))
452 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
453 refund_request(payment_intent_id, amount_cents, transaction_id)
454 .customize()
455 .account_id(acct)
456 .request_strategy(RequestStrategy::Idempotent(key))
457 .send(&self.client)
458 .await
459 .map_err(|e| {
460 tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund");
461 AppError::Internal(anyhow::anyhow!("Failed to create refund"))
462 })?;
463 Ok(())
464 }
465
466 /// Reimburse a creator for a platform-funded credit (the Fan+ renewal credit)
467 /// applied to their sale, so they still net the full pre-discount price and the
468 /// "0% platform fee, creators keep everything" promise holds. This is a platform
469 /// -> connected transfer funded from MNW's own balance (the platform absorbs the
470 /// credit, not the creator).
471 ///
472 /// The idempotency key is deterministic (`platform-credit-{transaction_id}`), so a
473 /// retry after a crash or transient failure returns the same transfer rather than
474 /// paying the creator twice.
475 ///
476 /// Returns the created transfer's Stripe id so the settle path can persist
477 /// it, the reversal path ([`create_platform_credit_reversal`]) needs it to
478 /// claw the funds back if the sale is later refunded.
479 #[tracing::instrument(skip_all, name = "payments::create_platform_credit_transfer")]
480 pub async fn create_platform_credit_transfer(
481 &self,
482 connected_account_id: &str,
483 amount_cents: i64,
484 transaction_id: crate::db::TransactionId,
485 currency: SettlementCurrency,
486 ) -> Result<String> {
487 let acct = Self::parse_account_id(connected_account_id)?;
488 let key = IdempotencyKey::new(platform_credit_key(transaction_id))
489 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
490 // Denominated in the sale's currency, not MNW's. The creator is owed the
491 // amount of a sale that was priced in their currency, so MNW carries any
492 // conversion out of its own balance rather than handing the creator a
493 // number that happens to match in USD.
494 let transfer = platform_credit_transfer_request(&acct, amount_cents, transaction_id, currency)
495 .customize()
496 .request_strategy(RequestStrategy::Idempotent(key))
497 .send(&self.client)
498 .await
499 .map_err(|e| {
500 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer");
501 AppError::Internal(anyhow::anyhow!("Failed to create transfer"))
502 })?;
503 Ok(transfer.id.to_string())
504 }
505
506 /// Reverse a settled platform-funded credit transfer when its sale is
507 /// refunded, pulling the reimbursed amount back from the connected account
508 /// to MNW so the platform isn't left funding a returned item.
509 ///
510 /// The idempotency key is deterministic
511 /// (`platform-credit-reversal-{transaction_id}`), so a retry after a crash
512 /// or transient failure returns the same reversal rather than clawing back
513 /// twice. `transfer_id` is the id captured when the forward transfer settled.
514 #[tracing::instrument(skip_all, name = "payments::create_platform_credit_reversal")]
515 pub async fn create_platform_credit_reversal(
516 &self,
517 transfer_id: &str,
518 amount_cents: i64,
519 transaction_id: crate::db::TransactionId,
520 ) -> Result<()> {
521 let key = IdempotencyKey::new(platform_credit_reversal_key(transaction_id))
522 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
523 platform_credit_reversal_request(transfer_id, amount_cents, transaction_id)
524 .customize()
525 .request_strategy(RequestStrategy::Idempotent(key))
526 .send(&self.client)
527 .await
528 .map_err(|e| {
529 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to reverse platform credit transfer");
530 AppError::Internal(anyhow::anyhow!("Failed to reverse transfer"))
531 })?;
532 Ok(())
533 }
534 }
535
536 #[cfg(test)]
537 mod tests {
538 use super::*;
539 use crate::db::TransactionId;
540
541 /// The form-encoded body a request would be sent with, decoded into pairs.
542 ///
543 /// `RequestBuilder` is what the transport is handed, so this is the last
544 /// point before the wire that a test can read. Percent-decoding it means an
545 /// assertion names the value Stripe parses rather than its encoding.
546 fn form(req: &impl StripeRequest) -> std::collections::BTreeMap<String, String> {
547 let built = req.build();
548 let body = built.body.unwrap_or_default();
549 url::form_urlencoded::parse(body.as_bytes())
550 .map(|(k, v)| (k.into_owned(), v.into_owned()))
551 .collect()
552 }
553
554 fn path_of(req: &impl StripeRequest) -> String {
555 req.build().path
556 }
557
558 fn method_of(req: &impl StripeRequest) -> String {
559 format!("{:?}", req.build().method)
560 }
561
562 // NOTE: async-stripe's `*Id` types are permissive newtypes, `FromStr`
563 // accepts any non-pathological string without validating the `acct_`/`sub_`
564 // prefix, so there is no error path to assert on normal input. These tests
565 // pin what is actually observable: canonical IDs parse and round-trip, and
566 // both account-id call sites now go through the single `parse_account_id`
567 // (the divergent `parse_account_id_internal` was deleted in Run #14).
568
569 #[test]
570 fn account_id_parses_and_round_trips() {
571 let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap();
572 assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G");
573 }
574
575 #[test]
576 fn subscription_id_parses_and_round_trips() {
577 let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap();
578 assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h");
579 }
580
581 // ── what each method puts on the wire ──
582 //
583 // Every StripeClient method below is a request builder plus a `send`, and
584 // the `send` half cannot be reached without calling Stripe. These pin the
585 // half that can: the path, the verb, and the fields. A missing field here
586 // is a real outage class rather than a coverage statistic: an account link
587 // with no `return_url` strands the creator on Stripe's page, and a refund
588 // with no `mnw_transaction_id` makes the webhook revoke the wrong line.
589
590 #[test]
591 fn a_connected_account_is_created_standard_and_named_by_email() {
592 let req = connect_account_request("creator@example.com");
593 assert_eq!(path_of(&req), "/accounts");
594 assert_eq!(method_of(&req), "Post");
595 let f = form(&req);
596 assert_eq!(f.get("type").map(String::as_str), Some("standard"));
597 assert_eq!(
598 f.get("email").map(String::as_str),
599 Some("creator@example.com")
600 );
601 }
602
603 #[test]
604 fn an_account_link_carries_both_urls_and_the_onboarding_type() {
605 let req = account_link_request(
606 "acct_1A2b3C4d5E6f7G",
607 "https://makenot.work/connect/return",
608 "https://makenot.work/connect/refresh",
609 );
610 assert_eq!(path_of(&req), "/account_links");
611 let f = form(&req);
612 assert_eq!(
613 f.get("account").map(String::as_str),
614 Some("acct_1A2b3C4d5E6f7G")
615 );
616 assert_eq!(
617 f.get("type").map(String::as_str),
618 Some("account_onboarding")
619 );
620 assert_eq!(
621 f.get("return_url").map(String::as_str),
622 Some("https://makenot.work/connect/return")
623 );
624 assert_eq!(
625 f.get("refresh_url").map(String::as_str),
626 Some("https://makenot.work/connect/refresh"),
627 "without a refresh url an expired link is a dead end"
628 );
629 }
630
631 #[test]
632 fn a_tier_product_sends_its_description_only_when_it_has_one() {
633 let with = subscription_product_request("Gold", Some("Everything"));
634 assert_eq!(path_of(&with), "/products");
635 let f = form(&with);
636 assert_eq!(f.get("name").map(String::as_str), Some("Gold"));
637 assert_eq!(f.get("description").map(String::as_str), Some("Everything"));
638
639 let without = subscription_product_request("Gold", None);
640 assert!(
641 !form(&without).contains_key("description"),
642 "an absent description is absent, not an empty string"
643 );
644 }
645
646 #[test]
647 fn a_tier_price_is_monthly_and_in_the_creators_currency() {
648 let req = subscription_price_request("prod_123", 1500, SettlementCurrency::Eur);
649 assert_eq!(path_of(&req), "/prices");
650 let f = form(&req);
651 assert_eq!(f.get("product").map(String::as_str), Some("prod_123"));
652 assert_eq!(f.get("unit_amount").map(String::as_str), Some("1500"));
653 assert_eq!(
654 f.get("currency").map(String::as_str),
655 Some("eur"),
656 "the tier is minted in the settlement currency, never re-denominated"
657 );
658 assert_eq!(
659 f.get("recurring[interval]").map(String::as_str),
660 Some("month"),
661 "without `recurring` Stripe bills this once instead of every month"
662 );
663 }
664
665 #[test]
666 fn pausing_voids_invoices_rather_than_cancelling() {
667 let req = pause_collection_request("sub_1A2b3C4d5E".parse().unwrap());
668 assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C4d5E");
669 assert_eq!(
670 form(&req)
671 .get("pause_collection[behavior]")
672 .map(String::as_str),
673 Some("void"),
674 "the fan is not billed while the creator is paused"
675 );
676 }
677
678 #[test]
679 fn cancel_at_period_end_states_the_flag_in_both_directions() {
680 let set = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), true);
681 assert_eq!(path_of(&set), "/subscriptions/sub_1A2b3C4d5E");
682 assert_eq!(
683 form(&set).get("cancel_at_period_end").map(String::as_str),
684 Some("true")
685 );
686 // Clearing it has to be sent, or an un-pause leaves the subscription
687 // still scheduled to end.
688 let cleared = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), false);
689 assert_eq!(
690 form(&cleared)
691 .get("cancel_at_period_end")
692 .map(String::as_str),
693 Some("false")
694 );
695 }
696
697 #[test]
698 fn a_billing_portal_session_names_the_customer_and_where_to_come_back_to() {
699 let req = billing_portal_request("cus_123", "https://makenot.work/settings");
700 assert_eq!(path_of(&req), "/billing_portal/sessions");
701 let f = form(&req);
702 assert_eq!(f.get("customer").map(String::as_str), Some("cus_123"));
703 assert_eq!(
704 f.get("return_url").map(String::as_str),
705 Some("https://makenot.work/settings")
706 );
707 }
708
709 #[test]
710 fn a_refund_is_line_scoped_and_tagged_with_its_transaction() {
711 let txn = TransactionId::nil();
712 let req = refund_request("pi_123", 250, txn);
713 assert_eq!(path_of(&req), "/refunds");
714 let f = form(&req);
715 assert_eq!(f.get("payment_intent").map(String::as_str), Some("pi_123"));
716 assert_eq!(
717 f.get("amount").map(String::as_str),
718 Some("250"),
719 "a cart order is one PaymentIntent, so an amount-less refund would \
720 reverse every line of it"
721 );
722 assert_eq!(
723 f.get("metadata[mnw_transaction_id]").map(String::as_str),
724 Some(txn.to_string()).as_deref(),
725 "the refund.created webhook revokes the transaction this names"
726 );
727 }
728
729 #[test]
730 fn a_platform_credit_transfer_is_denominated_in_the_sales_currency() {
731 let txn = TransactionId::nil();
732 let acct: stripe::AccountId = "acct_1A2b3C4d5E6f7G".parse().unwrap();
733 let req = platform_credit_transfer_request(&acct, 500, txn, SettlementCurrency::Eur);
734 assert_eq!(path_of(&req), "/transfers");
735 let f = form(&req);
736 assert_eq!(
737 f.get("destination").map(String::as_str),
738 Some("acct_1A2b3C4d5E6f7G")
739 );
740 assert_eq!(f.get("amount").map(String::as_str), Some("500"));
741 assert_eq!(
742 f.get("currency").map(String::as_str),
743 Some("eur"),
744 "the creator is owed the amount of a sale priced in their currency"
745 );
746 assert_eq!(
747 f.get("metadata[reason]").map(String::as_str),
748 Some("platform_funded_credit")
749 );
750 assert_eq!(
751 f.get("metadata[mnw_transaction_id]").map(String::as_str),
752 Some(txn.to_string()).as_deref()
753 );
754 }
755
756 #[test]
757 fn a_platform_credit_reversal_claws_back_against_its_transfer() {
758 let txn = TransactionId::nil();
759 let req = platform_credit_reversal_request("tr_123", 500, txn);
760 assert_eq!(path_of(&req), "/transfers/tr_123/reversals");
761 let f = form(&req);
762 assert_eq!(f.get("amount").map(String::as_str), Some("500"));
763 assert_eq!(
764 f.get("metadata[reason]").map(String::as_str),
765 Some("platform_funded_credit_reversal"),
766 "the forward transfer and its reversal must not read alike in the \
767 Stripe dashboard"
768 );
769 }
770
771 #[test]
772 fn the_three_money_keys_are_deterministic_and_distinct() {
773 // A retry has to return the same Stripe object; a reversal keyed like
774 // its forward transfer would return the transfer instead of clawing
775 // anything back.
776 let txn = TransactionId::nil();
777 assert_eq!(refund_key(txn), refund_key(txn));
778 assert_eq!(refund_key(txn), format!("refund-{txn}"));
779 let keys = [
780 refund_key(txn),
781 platform_credit_key(txn),
782 platform_credit_reversal_key(txn),
783 ];
784 let distinct: std::collections::BTreeSet<&String> = keys.iter().collect();
785 assert_eq!(distinct.len(), 3, "{keys:?}");
786
787 let other = TransactionId::new();
788 assert_ne!(
789 refund_key(txn),
790 refund_key(other),
791 "two transactions must not share a refund key"
792 );
793 }
794 }
795