//! Connected account operations: onboarding, balance, product/price creation, //! subscription lifecycle, refunds, and billing portal. use stripe::{IdempotencyKey, RequestStrategy, StripeRequest}; use stripe_billing::billing_portal_session::CreateBillingPortalSession; use stripe_billing::subscription::{ CancelSubscription, ResumeSubscription, UpdateSubscription, UpdateSubscriptionPauseCollection, UpdateSubscriptionPauseCollectionBehavior, }; use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount}; use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType}; use stripe_connect::transfer::CreateTransfer; use stripe_connect::transfer_reversal::CreateIdTransferReversal; use stripe_core::balance::RetrieveForMyAccountBalance; use stripe_core::refund::CreateRefund; use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval}; use stripe_product::product::CreateProduct; use super::StripeClient; use crate::currency::SettlementCurrency; use crate::db::StripeAccountId; use crate::error::{AppError, Result}; fn parse_subscription_id(stripe_sub_id: &str) -> Result { stripe_sub_id.parse().map_err(|e| { AppError::Internal(anyhow::anyhow!( "Invalid Stripe subscription ID '{stripe_sub_id}': {e}" )) }) } /// The `POST /accounts` body for a creator's Standard connected account. /// /// Split from the method that sends it so the request Stripe is handed can be /// read back in a test. Nothing here calls Stripe, and every builder below /// follows the same shape: the method parses ids, sends, and maps the error; /// the function states what goes on the wire. fn connect_account_request(email: &str) -> CreateAccount { CreateAccount::new() .type_(CreateAccountType::Standard) .email(email.to_string()) } /// The onboarding Account Link body. `CreateAccountLink` takes the account id /// as a plain String, not an `AccountId`. fn account_link_request( account_id: &str, return_url: &str, refresh_url: &str, ) -> CreateAccountLink { CreateAccountLink::new( account_id.to_string(), CreateAccountLinkType::AccountOnboarding, ) .return_url(return_url.to_string()) .refresh_url(refresh_url.to_string()) } /// The Product a creator tier is sold as. fn subscription_product_request(tier_name: &str, tier_description: Option<&str>) -> CreateProduct { let req = CreateProduct::new(tier_name.to_string()); match tier_description { Some(desc) => req.description(desc.to_string()), None => req, } } /// The monthly recurring Price for a creator tier, in the creator's /// settlement currency. fn subscription_price_request( product_id: &str, price_cents: i64, currency: SettlementCurrency, ) -> CreatePrice { CreatePrice::new(currency.to_stripe()) .product(product_id.to_string()) .unit_amount(price_cents) .recurring(CreatePriceRecurring::new( CreatePriceRecurringInterval::Month, )) } /// Pause collection by voiding invoices, rather than cancelling. fn pause_collection_request(sub_id: stripe_shared::SubscriptionId) -> UpdateSubscription { UpdateSubscription::new(sub_id).pause_collection(UpdateSubscriptionPauseCollection::new( UpdateSubscriptionPauseCollectionBehavior::Void, )) } /// Set or clear `cancel_at_period_end`. `cancel` is always sent, so clearing /// the flag is a state the request states rather than one it omits. fn cancel_at_period_end_request( sub_id: stripe_shared::SubscriptionId, cancel: bool, ) -> UpdateSubscription { UpdateSubscription::new(sub_id).cancel_at_period_end(cancel) } /// The Billing Portal session body. fn billing_portal_request( stripe_customer_id: &str, return_url: &str, ) -> CreateBillingPortalSession { CreateBillingPortalSession::new() .customer(stripe_customer_id.to_string()) .return_url(return_url.to_string()) } /// A line-scoped refund against the order's shared PaymentIntent, tagged with /// the transaction the `refund.created` webhook has to revoke. fn refund_request( payment_intent_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> CreateRefund { let metadata = std::collections::HashMap::from([( "mnw_transaction_id".to_string(), transaction_id.to_string(), )]); CreateRefund::new() .payment_intent(payment_intent_id.to_string()) .amount(amount_cents) .metadata(metadata) } /// The platform-funded credit reimbursement, denominated in the sale's /// currency rather than MNW's. fn platform_credit_transfer_request( acct: &stripe::AccountId, amount_cents: i64, transaction_id: crate::db::TransactionId, currency: SettlementCurrency, ) -> CreateTransfer { let metadata = std::collections::HashMap::from([ ("mnw_transaction_id".to_string(), transaction_id.to_string()), ("reason".to_string(), "platform_funded_credit".to_string()), ]); CreateTransfer::new(currency.to_stripe(), acct.to_string()) .amount(amount_cents) .description("Fan+ credit reimbursement") .metadata(metadata) } /// Claw a settled platform credit back when its sale is refunded. fn platform_credit_reversal_request( transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> CreateIdTransferReversal { let metadata = std::collections::HashMap::from([ ("mnw_transaction_id".to_string(), transaction_id.to_string()), ( "reason".to_string(), "platform_funded_credit_reversal".to_string(), ), ]); CreateIdTransferReversal::new(transfer_id.to_string()) .amount(amount_cents) .metadata(metadata) } /// Deterministic idempotency keys. A retry after a crash or a transient /// failure has to return the same Stripe object rather than debiting or /// paying a second time, and a transaction is refunded, reimbursed and /// reversed at most once each, so its id is the correct dedup scope. fn refund_key(transaction_id: crate::db::TransactionId) -> String { format!("refund-{transaction_id}") } fn platform_credit_key(transaction_id: crate::db::TransactionId) -> String { format!("platform-credit-{transaction_id}") } fn platform_credit_reversal_key(transaction_id: crate::db::TransactionId) -> String { format!("platform-credit-reversal-{transaction_id}") } impl StripeClient { /// Create a Stripe Standard connected account for a creator. #[tracing::instrument(skip_all, name = "payments::create_connect_account")] pub async fn create_connect_account(&self, email: &str) -> Result { let account = connect_account_request(email) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe connected account"); AppError::BadRequest("Failed to create Stripe account".to_string()) })?; // Stripe minted this id; trust its shape rather than re-validating. Ok(StripeAccountId::from_trusted(account.id.to_string())) } /// Create an Account Link for Stripe Connect onboarding. #[tracing::instrument(skip_all, name = "payments::create_account_link")] pub async fn create_account_link( &self, account_id: &str, return_url: &str, refresh_url: &str, ) -> Result { let link = account_link_request(account_id, return_url, refresh_url) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe account link"); AppError::BadRequest("Failed to create Stripe onboarding link".to_string()) })?; Ok(link.url) } /// Fetch a Stripe Connect account by ID. #[tracing::instrument(skip_all, name = "payments::fetch_account")] pub async fn fetch_account(&self, account_id: &str) -> Result { let account_id = Self::parse_account_id(account_id)?; let account = RetrieveAccount::new(account_id) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to fetch Stripe account"); AppError::BadRequest("Failed to fetch Stripe account".to_string()) })?; Ok(super::AccountUpdate::from(account)) } /// Create a Product + monthly recurring Price on a connected account. #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")] pub async fn create_subscription_product_and_price( &self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64, currency: SettlementCurrency, ) -> Result<(String, String)> { if price_cents <= 0 { return Err(AppError::BadRequest("Price must be positive".to_string())); } let acct = Self::parse_account_id(connected_account_id)?; let product = subscription_product_request(tier_name, tier_description) .customize() .account_id(acct.clone()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe product"); AppError::BadRequest("Failed to create subscription product".to_string()) })?; // The tier's Price is minted in the creator's settlement currency and // never re-denominated afterwards. A creator who later changes their // Stripe currency keeps tiers priced in the old one until they re-price, // which is the honest outcome: the number they typed meant that currency. let price = subscription_price_request(product.id.as_ref(), price_cents, currency) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe price"); AppError::BadRequest("Failed to create subscription price".to_string()) })?; Ok((product.id.to_string(), price.id.to_string())) } /// Retrieve the balance for a connected account. #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")] pub async fn get_connected_account_balance( &self, account_id: &str, ) -> Result { let acct = Self::parse_account_id(account_id)?; RetrieveForMyAccountBalance::new() .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to fetch Stripe balance"); AppError::BadRequest("Failed to fetch Stripe balance".to_string()) }) } /// Pause subscription collection (void invoices) on a connected account. #[tracing::instrument(skip_all, name = "payments::pause_subscription")] pub async fn pause_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; pause_collection_request(sub_id) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to pause subscription")) })?; Ok(()) } /// Resume a paused subscription on a connected account. /// /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift /// a pause; the legacy "clear `pause_collection`" trick is no longer needed. #[tracing::instrument(skip_all, name = "payments::resume_subscription")] pub async fn resume_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; ResumeSubscription::new(sub_id) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to resume subscription")) })?; Ok(()) } /// Cancel a subscription on a connected account (permanent). #[tracing::instrument(skip_all, name = "payments::cancel_subscription")] pub async fn cancel_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; CancelSubscription::new(sub_id) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to cancel subscription")) })?; Ok(()) } /// Cancel a platform-level subscription (creator tier, Fan+). #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")] pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> { let sub_id = parse_subscription_id(stripe_sub_id)?; CancelSubscription::new(sub_id) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription"); AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription")) })?; Ok(()) } /// Set or clear `cancel_at_period_end` on a platform-level subscription. #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")] pub async fn set_platform_cancel_at_period_end( &self, stripe_sub_id: &str, cancel: bool, ) -> Result<()> { let sub_id = parse_subscription_id(stripe_sub_id)?; cancel_at_period_end_request(sub_id, cancel) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end"); AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")) })?; Ok(()) } /// Set or clear `cancel_at_period_end` on a connected-account subscription. #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")] pub async fn set_cancel_at_period_end( &self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; cancel_at_period_end_request(sub_id, cancel) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set cancel_at_period_end"); AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")) })?; Ok(()) } /// Create a Stripe Billing Portal session for a customer. #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")] pub async fn create_billing_portal_session( &self, stripe_customer_id: &str, return_url: &str, ) -> Result { let session = billing_portal_request(stripe_customer_id, return_url) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create billing portal session"); AppError::Internal(anyhow::anyhow!("Failed to create billing portal session")) })?; Ok(session.url) } /// Issue a line-scoped refund for one transaction on a connected account. /// /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook /// marks and revokes exactly that transaction. Cart checkouts put every line /// of an order under ONE PaymentIntent, so a PI-wide refund would silently /// reverse the whole order (Run #2 Payments SERIOUS). #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")] pub async fn create_refund_for_transaction( &self, payment_intent_id: &str, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; // Deterministic idempotency key (`refund-{transaction_id}`), mirroring the // platform-credit transfer below: a retry after a crash or transient // failure returns the same refund rather than double-debiting the // creator's connected balance. A transaction is refunded in full exactly // once, so keying on its id is the correct dedup scope. let key = IdempotencyKey::new(refund_key(transaction_id)) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; refund_request(payment_intent_id, amount_cents, transaction_id) .customize() .account_id(acct) .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund"); AppError::Internal(anyhow::anyhow!("Failed to create refund")) })?; Ok(()) } /// Reimburse a creator for a platform-funded credit (the Fan+ renewal credit) /// applied to their sale, so they still net the full pre-discount price and the /// "0% platform fee, creators keep everything" promise holds. This is a platform /// -> connected transfer funded from MNW's own balance (the platform absorbs the /// credit, not the creator). /// /// The idempotency key is deterministic (`platform-credit-{transaction_id}`), so a /// retry after a crash or transient failure returns the same transfer rather than /// paying the creator twice. /// /// Returns the created transfer's Stripe id so the settle path can persist /// it, the reversal path ([`create_platform_credit_reversal`]) needs it to /// claw the funds back if the sale is later refunded. #[tracing::instrument(skip_all, name = "payments::create_platform_credit_transfer")] pub async fn create_platform_credit_transfer( &self, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, currency: SettlementCurrency, ) -> Result { let acct = Self::parse_account_id(connected_account_id)?; let key = IdempotencyKey::new(platform_credit_key(transaction_id)) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; // Denominated in the sale's currency, not MNW's. The creator is owed the // amount of a sale that was priced in their currency, so MNW carries any // conversion out of its own balance rather than handing the creator a // number that happens to match in USD. let transfer = platform_credit_transfer_request(&acct, amount_cents, transaction_id, currency) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer"); AppError::Internal(anyhow::anyhow!("Failed to create transfer")) })?; Ok(transfer.id.to_string()) } /// Reverse a settled platform-funded credit transfer when its sale is /// refunded, pulling the reimbursed amount back from the connected account /// to MNW so the platform isn't left funding a returned item. /// /// The idempotency key is deterministic /// (`platform-credit-reversal-{transaction_id}`), so a retry after a crash /// or transient failure returns the same reversal rather than clawing back /// twice. `transfer_id` is the id captured when the forward transfer settled. #[tracing::instrument(skip_all, name = "payments::create_platform_credit_reversal")] pub async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> Result<()> { let key = IdempotencyKey::new(platform_credit_reversal_key(transaction_id)) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; platform_credit_reversal_request(transfer_id, amount_cents, transaction_id) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to reverse platform credit transfer"); AppError::Internal(anyhow::anyhow!("Failed to reverse transfer")) })?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::db::TransactionId; /// The form-encoded body a request would be sent with, decoded into pairs. /// /// `RequestBuilder` is what the transport is handed, so this is the last /// point before the wire that a test can read. Percent-decoding it means an /// assertion names the value Stripe parses rather than its encoding. fn form(req: &impl StripeRequest) -> std::collections::BTreeMap { let built = req.build(); let body = built.body.unwrap_or_default(); url::form_urlencoded::parse(body.as_bytes()) .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect() } fn path_of(req: &impl StripeRequest) -> String { req.build().path } fn method_of(req: &impl StripeRequest) -> String { format!("{:?}", req.build().method) } // NOTE: async-stripe's `*Id` types are permissive newtypes, `FromStr` // accepts any non-pathological string without validating the `acct_`/`sub_` // prefix, so there is no error path to assert on normal input. These tests // pin what is actually observable: canonical IDs parse and round-trip, and // both account-id call sites now go through the single `parse_account_id` // (the divergent `parse_account_id_internal` was deleted in Run #14). #[test] fn account_id_parses_and_round_trips() { let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap(); assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G"); } #[test] fn subscription_id_parses_and_round_trips() { let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap(); assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h"); } // ── what each method puts on the wire ── // // Every StripeClient method below is a request builder plus a `send`, and // the `send` half cannot be reached without calling Stripe. These pin the // half that can: the path, the verb, and the fields. A missing field here // is a real outage class rather than a coverage statistic: an account link // with no `return_url` strands the creator on Stripe's page, and a refund // with no `mnw_transaction_id` makes the webhook revoke the wrong line. #[test] fn a_connected_account_is_created_standard_and_named_by_email() { let req = connect_account_request("creator@example.com"); assert_eq!(path_of(&req), "/accounts"); assert_eq!(method_of(&req), "Post"); let f = form(&req); assert_eq!(f.get("type").map(String::as_str), Some("standard")); assert_eq!( f.get("email").map(String::as_str), Some("creator@example.com") ); } #[test] fn an_account_link_carries_both_urls_and_the_onboarding_type() { let req = account_link_request( "acct_1A2b3C4d5E6f7G", "https://makenot.work/connect/return", "https://makenot.work/connect/refresh", ); assert_eq!(path_of(&req), "/account_links"); let f = form(&req); assert_eq!( f.get("account").map(String::as_str), Some("acct_1A2b3C4d5E6f7G") ); assert_eq!( f.get("type").map(String::as_str), Some("account_onboarding") ); assert_eq!( f.get("return_url").map(String::as_str), Some("https://makenot.work/connect/return") ); assert_eq!( f.get("refresh_url").map(String::as_str), Some("https://makenot.work/connect/refresh"), "without a refresh url an expired link is a dead end" ); } #[test] fn a_tier_product_sends_its_description_only_when_it_has_one() { let with = subscription_product_request("Gold", Some("Everything")); assert_eq!(path_of(&with), "/products"); let f = form(&with); assert_eq!(f.get("name").map(String::as_str), Some("Gold")); assert_eq!(f.get("description").map(String::as_str), Some("Everything")); let without = subscription_product_request("Gold", None); assert!( !form(&without).contains_key("description"), "an absent description is absent, not an empty string" ); } #[test] fn a_tier_price_is_monthly_and_in_the_creators_currency() { let req = subscription_price_request("prod_123", 1500, SettlementCurrency::Eur); assert_eq!(path_of(&req), "/prices"); let f = form(&req); assert_eq!(f.get("product").map(String::as_str), Some("prod_123")); assert_eq!(f.get("unit_amount").map(String::as_str), Some("1500")); assert_eq!( f.get("currency").map(String::as_str), Some("eur"), "the tier is minted in the settlement currency, never re-denominated" ); assert_eq!( f.get("recurring[interval]").map(String::as_str), Some("month"), "without `recurring` Stripe bills this once instead of every month" ); } #[test] fn pausing_voids_invoices_rather_than_cancelling() { let req = pause_collection_request("sub_1A2b3C4d5E".parse().unwrap()); assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C4d5E"); assert_eq!( form(&req) .get("pause_collection[behavior]") .map(String::as_str), Some("void"), "the fan is not billed while the creator is paused" ); } #[test] fn cancel_at_period_end_states_the_flag_in_both_directions() { let set = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), true); assert_eq!(path_of(&set), "/subscriptions/sub_1A2b3C4d5E"); assert_eq!( form(&set).get("cancel_at_period_end").map(String::as_str), Some("true") ); // Clearing it has to be sent, or an un-pause leaves the subscription // still scheduled to end. let cleared = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), false); assert_eq!( form(&cleared) .get("cancel_at_period_end") .map(String::as_str), Some("false") ); } #[test] fn a_billing_portal_session_names_the_customer_and_where_to_come_back_to() { let req = billing_portal_request("cus_123", "https://makenot.work/settings"); assert_eq!(path_of(&req), "/billing_portal/sessions"); let f = form(&req); assert_eq!(f.get("customer").map(String::as_str), Some("cus_123")); assert_eq!( f.get("return_url").map(String::as_str), Some("https://makenot.work/settings") ); } #[test] fn a_refund_is_line_scoped_and_tagged_with_its_transaction() { let txn = TransactionId::nil(); let req = refund_request("pi_123", 250, txn); assert_eq!(path_of(&req), "/refunds"); let f = form(&req); assert_eq!(f.get("payment_intent").map(String::as_str), Some("pi_123")); assert_eq!( f.get("amount").map(String::as_str), Some("250"), "a cart order is one PaymentIntent, so an amount-less refund would \ reverse every line of it" ); assert_eq!( f.get("metadata[mnw_transaction_id]").map(String::as_str), Some(txn.to_string()).as_deref(), "the refund.created webhook revokes the transaction this names" ); } #[test] fn a_platform_credit_transfer_is_denominated_in_the_sales_currency() { let txn = TransactionId::nil(); let acct: stripe::AccountId = "acct_1A2b3C4d5E6f7G".parse().unwrap(); let req = platform_credit_transfer_request(&acct, 500, txn, SettlementCurrency::Eur); assert_eq!(path_of(&req), "/transfers"); let f = form(&req); assert_eq!( f.get("destination").map(String::as_str), Some("acct_1A2b3C4d5E6f7G") ); assert_eq!(f.get("amount").map(String::as_str), Some("500")); assert_eq!( f.get("currency").map(String::as_str), Some("eur"), "the creator is owed the amount of a sale priced in their currency" ); assert_eq!( f.get("metadata[reason]").map(String::as_str), Some("platform_funded_credit") ); assert_eq!( f.get("metadata[mnw_transaction_id]").map(String::as_str), Some(txn.to_string()).as_deref() ); } #[test] fn a_platform_credit_reversal_claws_back_against_its_transfer() { let txn = TransactionId::nil(); let req = platform_credit_reversal_request("tr_123", 500, txn); assert_eq!(path_of(&req), "/transfers/tr_123/reversals"); let f = form(&req); assert_eq!(f.get("amount").map(String::as_str), Some("500")); assert_eq!( f.get("metadata[reason]").map(String::as_str), Some("platform_funded_credit_reversal"), "the forward transfer and its reversal must not read alike in the \ Stripe dashboard" ); } #[test] fn the_three_money_keys_are_deterministic_and_distinct() { // A retry has to return the same Stripe object; a reversal keyed like // its forward transfer would return the transfer instead of clawing // anything back. let txn = TransactionId::nil(); assert_eq!(refund_key(txn), refund_key(txn)); assert_eq!(refund_key(txn), format!("refund-{txn}")); let keys = [ refund_key(txn), platform_credit_key(txn), platform_credit_reversal_key(txn), ]; let distinct: std::collections::BTreeSet<&String> = keys.iter().collect(); assert_eq!(distinct.len(), 3, "{keys:?}"); let other = TransactionId::new(); assert_ne!( refund_key(txn), refund_key(other), "two transactions must not share a refund key" ); } }