//! Parsed metadata types for Stripe Checkout sessions, plus type-discriminator helpers. //! //! Each variant (`CheckoutMetadata`, `SubscriptionCheckoutMetadata`, etc.) corresponds //! to one of the `CheckoutType` flavors set in the session's `metadata.checkout_type` //! field at creation time. The `from_metadata` constructors parse the relevant fields //! back out at webhook time. use std::collections::HashMap; use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; use crate::error::{AppError, Result}; /// Convenience alias for the metadata pulled off a Stripe `CheckoutSession`. pub type CheckoutMetaMap = HashMap; fn require<'a>(meta: Option<&'a CheckoutMetaMap>, key: &str) -> Result<&'a String> { meta.and_then(|m| m.get(key)) .ok_or_else(|| AppError::BadRequest(format!("Missing {key} in metadata"))) } fn parse_uuid_to>(value: &str, field: &'static str) -> Result { value.parse::() .map(T::from) .map_err(|_| AppError::BadRequest(format!("Invalid {field} format"))) } /// Parsed metadata from a one-time purchase checkout session. #[derive(Debug)] pub struct CheckoutMetadata { pub buyer_id: UserId, pub seller_id: UserId, pub item_id: Option, pub promo_code_id: Option, } impl CheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?; let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?; let item_id = meta.and_then(|m| m.get("item_id")) .and_then(|v| v.parse::().ok().map(ItemId::from)); let promo_code_id = meta.and_then(|m| m.get("promo_code_id")) .and_then(|v| v.parse::().ok().map(PromoCodeId::from)); Ok(CheckoutMetadata { buyer_id, seller_id, item_id, promo_code_id }) } } /// Parsed metadata from a subscription checkout session. #[derive(Debug)] pub struct SubscriptionCheckoutMetadata { pub subscriber_id: UserId, pub project_id: ProjectId, pub tier_id: SubscriptionTierId, pub promo_code_id: Option, } impl SubscriptionCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let subscriber_id: UserId = parse_uuid_to(require(meta, "subscriber_id")?, "subscriber_id")?; let project_id: ProjectId = parse_uuid_to(require(meta, "project_id")?, "project_id")?; let tier_id: SubscriptionTierId = parse_uuid_to(require(meta, "tier_id")?, "tier_id")?; let promo_code_id = meta.and_then(|m| m.get("promo_code_id")) .and_then(|v| v.parse::().ok().map(PromoCodeId::from)); Ok(SubscriptionCheckoutMetadata { subscriber_id, project_id, tier_id, promo_code_id }) } } /// Parsed metadata from a Fan+ checkout session. #[derive(Debug)] pub struct FanPlusCheckoutMetadata { pub user_id: UserId, } impl FanPlusCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; Ok(FanPlusCheckoutMetadata { user_id }) } } /// Parsed metadata from a creator tier checkout session. #[derive(Debug)] pub struct CreatorTierCheckoutMetadata { pub user_id: UserId, pub tier: String, } impl CreatorTierCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; let tier = require(meta, "tier")?.clone(); Ok(CreatorTierCheckoutMetadata { user_id, tier }) } } /// Parsed metadata from a tip checkout session. #[derive(Debug)] pub struct TipCheckoutMetadata { pub tipper_id: UserId, pub recipient_id: UserId, pub project_id: Option, pub message: Option, } impl TipCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let tipper_id: UserId = parse_uuid_to(require(meta, "tipper_id")?, "tipper_id")?; let recipient_id: UserId = parse_uuid_to(require(meta, "recipient_id")?, "recipient_id")?; let project_id = meta.and_then(|m| m.get("project_id")) .and_then(|v| v.parse::().ok().map(ProjectId::from)); let message = meta.and_then(|m| m.get("message")).cloned(); Ok(TipCheckoutMetadata { tipper_id, recipient_id, project_id, message }) } } /// Parsed metadata from a cart (multi-item) checkout session. #[derive(Debug)] pub struct CartCheckoutMetadata { pub buyer_id: UserId, pub seller_id: UserId, } impl CartCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?; let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?; Ok(CartCheckoutMetadata { buyer_id, seller_id }) } } /// Parsed metadata from a guest checkout session. #[derive(Debug)] pub struct GuestCheckoutMetadata { pub seller_id: UserId, pub item_id: ItemId, pub promo_code_id: Option, } impl GuestCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?; let item_id: ItemId = parse_uuid_to(require(meta, "item_id")?, "item_id")?; let promo_code_id = meta.and_then(|m| m.get("promo_code_id")) .and_then(|v| v.parse::().ok().map(PromoCodeId::from)); Ok(GuestCheckoutMetadata { seller_id, item_id, promo_code_id }) } } /// Extract the checkout type from a Stripe session's metadata. pub fn get_checkout_type(meta: Option<&CheckoutMetaMap>) -> Option { meta.and_then(|m| m.get("checkout_type")) .and_then(|t| t.parse().ok()) } pub fn is_tip_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::Tip) } pub fn is_fan_plus_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::FanPlus) } pub fn is_creator_tier_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::CreatorTier) } pub fn is_subscription_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::Subscription) } pub fn is_guest_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::Guest) } pub fn is_cart_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::Cart) } pub fn is_synckit_app_sub_checkout(meta: Option<&CheckoutMetaMap>) -> bool { get_checkout_type(meta) == Some(CheckoutType::SynckitAppSub) } /// Parsed metadata for an end-user subscribing to an app's cloud sync. #[derive(Debug)] pub struct SynckitAppSubCheckoutMetadata { pub user_id: UserId, pub app_id: SyncAppId, pub tier: String, pub storage_limit_bytes: Option, } impl SynckitAppSubCheckoutMetadata { pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result { let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?; let tier = require(meta, "tier")?.clone(); let storage_limit_bytes = meta .and_then(|m| m.get("storage_limit_bytes")) .and_then(|v| v.parse::().ok()); Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, tier, storage_limit_bytes }) } } #[cfg(test)] mod tests { use super::*; fn meta_of(entries: &[(&str, &str)]) -> CheckoutMetaMap { entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() } // --- CheckoutMetadata --- #[test] fn from_metadata_valid() { let buyer = UserId::new(); let seller = UserId::new(); let item = ItemId::new(); let m = meta_of(&[ ("buyer_id", &buyer.to_string()), ("seller_id", &seller.to_string()), ("item_id", &item.to_string()), ]); let r = CheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.buyer_id, buyer); assert_eq!(r.seller_id, seller); assert_eq!(r.item_id, Some(item)); } #[test] fn from_metadata_missing_metadata() { assert!(CheckoutMetadata::from_metadata(None).is_err()); } #[test] fn from_metadata_missing_buyer_id() { let m = meta_of(&[("seller_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]); assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err()); } #[test] fn from_metadata_missing_seller_id() { let m = meta_of(&[("buyer_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]); assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err()); } #[test] fn from_metadata_invalid_uuid_format() { let m = meta_of(&[ ("buyer_id", "not-a-uuid"), ("seller_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string()), ]); assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err()); } #[test] fn from_metadata_empty() { let m = HashMap::new(); assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err()); } // --- SubscriptionCheckoutMetadata --- #[test] fn sub_metadata_valid() { let s = UserId::new(); let p = ProjectId::new(); let t = SubscriptionTierId::new(); let m = meta_of(&[ ("subscriber_id", &s.to_string()), ("project_id", &p.to_string()), ("tier_id", &t.to_string()), ("checkout_type", "subscription"), ]); let r = SubscriptionCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.subscriber_id, s); assert_eq!(r.project_id, p); assert_eq!(r.tier_id, t); } #[test] fn sub_metadata_missing_tier_id() { let m = meta_of(&[ ("subscriber_id", &UserId::new().to_string()), ("project_id", &ProjectId::new().to_string()), ]); assert!(SubscriptionCheckoutMetadata::from_metadata(Some(&m)).is_err()); } #[test] fn sub_metadata_missing_all() { assert!(SubscriptionCheckoutMetadata::from_metadata(None).is_err()); } // --- is_*_checkout --- #[test] fn is_subscription_checkout_true() { let m = meta_of(&[("checkout_type", "subscription")]); assert!(is_subscription_checkout(Some(&m))); } #[test] fn is_subscription_checkout_false_no_metadata() { assert!(!is_subscription_checkout(None)); } #[test] fn is_subscription_checkout_false_purchase() { let m = meta_of(&[("buyer_id", &UserId::new().to_string())]); assert!(!is_subscription_checkout(Some(&m))); } // --- CartCheckoutMetadata --- #[test] fn cart_metadata_valid() { let b = UserId::new(); let s = UserId::new(); let m = meta_of(&[ ("checkout_type", "cart"), ("buyer_id", &b.to_string()), ("seller_id", &s.to_string()), ]); let r = CartCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.buyer_id, b); assert_eq!(r.seller_id, s); } #[test] fn cart_metadata_missing_buyer_id() { let m = meta_of(&[("checkout_type", "cart"), ("seller_id", &UserId::new().to_string())]); assert!(CartCheckoutMetadata::from_metadata(Some(&m)).is_err()); } #[test] fn cart_metadata_missing_metadata() { assert!(CartCheckoutMetadata::from_metadata(None).is_err()); } // --- GuestCheckoutMetadata --- #[test] fn guest_metadata_valid_with_promo() { let s = UserId::new(); let i = ItemId::new(); let p = PromoCodeId::new(); let m = meta_of(&[ ("checkout_type", "guest"), ("seller_id", &s.to_string()), ("item_id", &i.to_string()), ("promo_code_id", &p.to_string()), ]); let r = GuestCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.seller_id, s); assert_eq!(r.item_id, i); assert_eq!(r.promo_code_id, Some(p)); } #[test] fn guest_metadata_valid_without_promo() { let s = UserId::new(); let i = ItemId::new(); let m = meta_of(&[ ("checkout_type", "guest"), ("seller_id", &s.to_string()), ("item_id", &i.to_string()), ]); let r = GuestCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.seller_id, s); assert_eq!(r.item_id, i); assert_eq!(r.promo_code_id, None); } #[test] fn guest_metadata_missing_seller_id() { let m = meta_of(&[("checkout_type", "guest"), ("item_id", &ItemId::new().to_string())]); assert!(GuestCheckoutMetadata::from_metadata(Some(&m)).is_err()); } // --- TipCheckoutMetadata --- #[test] fn tip_metadata_valid_full() { let t = UserId::new(); let r2 = UserId::new(); let p = ProjectId::new(); let m = meta_of(&[ ("checkout_type", "tip"), ("tipper_id", &t.to_string()), ("recipient_id", &r2.to_string()), ("project_id", &p.to_string()), ("message", "Great work!"), ]); let r = TipCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.tipper_id, t); assert_eq!(r.recipient_id, r2); assert_eq!(r.project_id, Some(p)); assert_eq!(r.message.as_deref(), Some("Great work!")); } #[test] fn tip_metadata_valid_minimal() { let t = UserId::new(); let r2 = UserId::new(); let m = meta_of(&[ ("checkout_type", "tip"), ("tipper_id", &t.to_string()), ("recipient_id", &r2.to_string()), ]); let r = TipCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.project_id, None); assert_eq!(r.message, None); } #[test] fn tip_metadata_missing_tipper_id() { let m = meta_of(&[("checkout_type", "tip"), ("recipient_id", &UserId::new().to_string())]); assert!(TipCheckoutMetadata::from_metadata(Some(&m)).is_err()); } // --- is_* combinations --- #[test] fn is_cart_checkout_true() { let m = meta_of(&[("checkout_type", "cart")]); assert!(is_cart_checkout(Some(&m))); } #[test] fn is_cart_checkout_false_for_item() { let m = meta_of(&[("buyer_id", &UserId::new().to_string())]); assert!(!is_cart_checkout(Some(&m))); } #[test] fn is_guest_checkout_true() { let m = meta_of(&[("checkout_type", "guest")]); assert!(is_guest_checkout(Some(&m))); } #[test] fn is_tip_checkout_true() { let m = meta_of(&[("checkout_type", "tip")]); assert!(is_tip_checkout(Some(&m))); } #[test] fn is_fan_plus_checkout_true() { let m = meta_of(&[("checkout_type", "fan_plus")]); assert!(is_fan_plus_checkout(Some(&m))); } #[test] fn is_creator_tier_checkout_true() { let m = meta_of(&[("checkout_type", "creator_tier")]); assert!(is_creator_tier_checkout(Some(&m))); } // --- FanPlusCheckoutMetadata --- #[test] fn fan_plus_metadata_valid() { let u = UserId::new(); let m = meta_of(&[("checkout_type", "fan_plus"), ("user_id", &u.to_string())]); let r = FanPlusCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.user_id, u); } #[test] fn fan_plus_metadata_missing_user_id() { let m = meta_of(&[("checkout_type", "fan_plus")]); let err = FanPlusCheckoutMetadata::from_metadata(Some(&m)).unwrap_err(); assert!(format!("{err:?}").contains("user_id")); } // --- CreatorTierCheckoutMetadata --- #[test] fn creator_tier_metadata_valid() { let u = UserId::new(); let m = meta_of(&[ ("checkout_type", "creator_tier"), ("user_id", &u.to_string()), ("tier", "everything"), ]); let r = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.user_id, u); assert_eq!(r.tier, "everything"); } #[test] fn creator_tier_metadata_preserves_tier_string_verbatim() { let u = UserId::new(); let m = meta_of(&[("user_id", &u.to_string()), ("tier", "Big Files")]); let r = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap(); assert_eq!(r.tier, "Big Files"); } #[test] fn creator_tier_metadata_missing_tier() { let m = meta_of(&[("user_id", &UserId::new().to_string())]); let err = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap_err(); assert!(format!("{err:?}").contains("tier")); } // --- get_checkout_type --- #[test] fn get_checkout_type_recognises_each_variant() { for (s, expected) in [ ("tip", CheckoutType::Tip), ("fan_plus", CheckoutType::FanPlus), ("creator_tier", CheckoutType::CreatorTier), ("subscription", CheckoutType::Subscription), ("guest", CheckoutType::Guest), ("cart", CheckoutType::Cart), ] { let m = meta_of(&[("checkout_type", s)]); assert_eq!(get_checkout_type(Some(&m)), Some(expected)); } } #[test] fn get_checkout_type_unknown_string_is_none() { let m = meta_of(&[("checkout_type", "not-a-known-type")]); assert_eq!(get_checkout_type(Some(&m)), None); } #[test] fn get_checkout_type_missing_field_is_none() { let m = HashMap::new(); assert_eq!(get_checkout_type(Some(&m)), None); } #[test] fn is_predicates_dont_cross_match() { let m = meta_of(&[("checkout_type", "tip")]); let meta = Some(&m); assert!(is_tip_checkout(meta)); assert!(!is_fan_plus_checkout(meta)); assert!(!is_creator_tier_checkout(meta)); assert!(!is_subscription_checkout(meta)); assert!(!is_guest_checkout(meta)); assert!(!is_cart_checkout(meta)); } }