Skip to main content

max / makenotwork

18.2 KB · 540 lines History Blame Raw
1 //! Parsed metadata types for Stripe Checkout sessions, plus type-discriminator helpers.
2 //!
3 //! Each variant (`CheckoutMetadata`, `SubscriptionCheckoutMetadata`, etc.) corresponds
4 //! to one of the `CheckoutType` flavors set in the session's `metadata.checkout_type`
5 //! field at creation time. The `from_metadata` constructors parse the relevant fields
6 //! back out at webhook time.
7
8 use std::collections::HashMap;
9
10 use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId};
11 use crate::error::{AppError, Result};
12
13 /// Convenience alias for the metadata pulled off a Stripe `CheckoutSession`.
14 pub type CheckoutMetaMap = HashMap<String, String>;
15
16 fn require<'a>(meta: Option<&'a CheckoutMetaMap>, key: &str) -> Result<&'a String> {
17 meta.and_then(|m| m.get(key))
18 .ok_or_else(|| AppError::BadRequest(format!("Missing {key} in metadata")))
19 }
20
21 fn parse_uuid_to<T: From<uuid::Uuid>>(value: &str, field: &'static str) -> Result<T> {
22 value.parse::<uuid::Uuid>()
23 .map(T::from)
24 .map_err(|_| AppError::BadRequest(format!("Invalid {field} format")))
25 }
26
27 /// Parsed metadata from a one-time purchase checkout session.
28 #[derive(Debug)]
29 pub struct CheckoutMetadata {
30 pub buyer_id: UserId,
31 pub seller_id: UserId,
32 pub item_id: Option<ItemId>,
33 pub promo_code_id: Option<PromoCodeId>,
34 }
35
36 impl CheckoutMetadata {
37 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
38 let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?;
39 let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
40 let item_id = meta.and_then(|m| m.get("item_id"))
41 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(ItemId::from));
42 let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
43 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
44 Ok(CheckoutMetadata { buyer_id, seller_id, item_id, promo_code_id })
45 }
46 }
47
48 /// Parsed metadata from a subscription checkout session.
49 #[derive(Debug)]
50 pub struct SubscriptionCheckoutMetadata {
51 pub subscriber_id: UserId,
52 pub project_id: ProjectId,
53 pub tier_id: SubscriptionTierId,
54 pub promo_code_id: Option<PromoCodeId>,
55 }
56
57 impl SubscriptionCheckoutMetadata {
58 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
59 let subscriber_id: UserId = parse_uuid_to(require(meta, "subscriber_id")?, "subscriber_id")?;
60 let project_id: ProjectId = parse_uuid_to(require(meta, "project_id")?, "project_id")?;
61 let tier_id: SubscriptionTierId = parse_uuid_to(require(meta, "tier_id")?, "tier_id")?;
62 let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
63 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
64 Ok(SubscriptionCheckoutMetadata { subscriber_id, project_id, tier_id, promo_code_id })
65 }
66 }
67
68 /// Parsed metadata from a Fan+ checkout session.
69 #[derive(Debug)]
70 pub struct FanPlusCheckoutMetadata {
71 pub user_id: UserId,
72 }
73
74 impl FanPlusCheckoutMetadata {
75 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
76 let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
77 Ok(FanPlusCheckoutMetadata { user_id })
78 }
79 }
80
81 /// Parsed metadata from a creator tier checkout session.
82 #[derive(Debug)]
83 pub struct CreatorTierCheckoutMetadata {
84 pub user_id: UserId,
85 pub tier: String,
86 }
87
88 impl CreatorTierCheckoutMetadata {
89 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
90 let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
91 let tier = require(meta, "tier")?.clone();
92 Ok(CreatorTierCheckoutMetadata { user_id, tier })
93 }
94 }
95
96 /// Parsed metadata from a tip checkout session.
97 #[derive(Debug)]
98 pub struct TipCheckoutMetadata {
99 pub tipper_id: UserId,
100 pub recipient_id: UserId,
101 pub project_id: Option<ProjectId>,
102 pub message: Option<String>,
103 }
104
105 impl TipCheckoutMetadata {
106 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
107 let tipper_id: UserId = parse_uuid_to(require(meta, "tipper_id")?, "tipper_id")?;
108 let recipient_id: UserId = parse_uuid_to(require(meta, "recipient_id")?, "recipient_id")?;
109 let project_id = meta.and_then(|m| m.get("project_id"))
110 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(ProjectId::from));
111 let message = meta.and_then(|m| m.get("message")).cloned();
112 Ok(TipCheckoutMetadata { tipper_id, recipient_id, project_id, message })
113 }
114 }
115
116 /// Parsed metadata from a cart (multi-item) checkout session.
117 #[derive(Debug)]
118 pub struct CartCheckoutMetadata {
119 pub buyer_id: UserId,
120 pub seller_id: UserId,
121 }
122
123 impl CartCheckoutMetadata {
124 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
125 let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?;
126 let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
127 Ok(CartCheckoutMetadata { buyer_id, seller_id })
128 }
129 }
130
131 /// Parsed metadata from a guest checkout session.
132 #[derive(Debug)]
133 pub struct GuestCheckoutMetadata {
134 pub seller_id: UserId,
135 pub item_id: ItemId,
136 pub promo_code_id: Option<PromoCodeId>,
137 }
138
139 impl GuestCheckoutMetadata {
140 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
141 let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
142 let item_id: ItemId = parse_uuid_to(require(meta, "item_id")?, "item_id")?;
143 let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
144 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
145 Ok(GuestCheckoutMetadata { seller_id, item_id, promo_code_id })
146 }
147 }
148
149 /// Extract the checkout type from a Stripe session's metadata.
150 pub fn get_checkout_type(meta: Option<&CheckoutMetaMap>) -> Option<CheckoutType> {
151 meta.and_then(|m| m.get("checkout_type"))
152 .and_then(|t| t.parse().ok())
153 }
154
155 pub fn is_tip_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
156 get_checkout_type(meta) == Some(CheckoutType::Tip)
157 }
158
159 pub fn is_fan_plus_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
160 get_checkout_type(meta) == Some(CheckoutType::FanPlus)
161 }
162
163 pub fn is_creator_tier_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
164 get_checkout_type(meta) == Some(CheckoutType::CreatorTier)
165 }
166
167 pub fn is_subscription_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
168 get_checkout_type(meta) == Some(CheckoutType::Subscription)
169 }
170
171 pub fn is_guest_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
172 get_checkout_type(meta) == Some(CheckoutType::Guest)
173 }
174
175 pub fn is_cart_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
176 get_checkout_type(meta) == Some(CheckoutType::Cart)
177 }
178
179 pub fn is_synckit_app_sub_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
180 get_checkout_type(meta) == Some(CheckoutType::SynckitAppSub)
181 }
182
183 /// Parsed metadata for an end-user subscribing to an app's cloud sync.
184 #[derive(Debug)]
185 pub struct SynckitAppSubCheckoutMetadata {
186 pub user_id: UserId,
187 pub app_id: SyncAppId,
188 pub tier: String,
189 pub storage_limit_bytes: Option<i64>,
190 }
191
192 impl SynckitAppSubCheckoutMetadata {
193 pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
194 let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
195 let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?;
196 let tier = require(meta, "tier")?.clone();
197 let storage_limit_bytes = meta
198 .and_then(|m| m.get("storage_limit_bytes"))
199 .and_then(|v| v.parse::<i64>().ok());
200 Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, tier, storage_limit_bytes })
201 }
202 }
203
204 #[cfg(test)]
205 mod tests {
206 use super::*;
207
208 fn meta_of(entries: &[(&str, &str)]) -> CheckoutMetaMap {
209 entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
210 }
211
212 // --- CheckoutMetadata ---
213
214 #[test]
215 fn from_metadata_valid() {
216 let buyer = UserId::new();
217 let seller = UserId::new();
218 let item = ItemId::new();
219 let m = meta_of(&[
220 ("buyer_id", &buyer.to_string()),
221 ("seller_id", &seller.to_string()),
222 ("item_id", &item.to_string()),
223 ]);
224 let r = CheckoutMetadata::from_metadata(Some(&m)).unwrap();
225 assert_eq!(r.buyer_id, buyer);
226 assert_eq!(r.seller_id, seller);
227 assert_eq!(r.item_id, Some(item));
228 }
229
230 #[test]
231 fn from_metadata_missing_metadata() {
232 assert!(CheckoutMetadata::from_metadata(None).is_err());
233 }
234
235 #[test]
236 fn from_metadata_missing_buyer_id() {
237 let m = meta_of(&[("seller_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]);
238 assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
239 }
240
241 #[test]
242 fn from_metadata_missing_seller_id() {
243 let m = meta_of(&[("buyer_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]);
244 assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
245 }
246
247 #[test]
248 fn from_metadata_invalid_uuid_format() {
249 let m = meta_of(&[
250 ("buyer_id", "not-a-uuid"),
251 ("seller_id", &UserId::new().to_string()),
252 ("item_id", &ItemId::new().to_string()),
253 ]);
254 assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
255 }
256
257 #[test]
258 fn from_metadata_empty() {
259 let m = HashMap::new();
260 assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
261 }
262
263 // --- SubscriptionCheckoutMetadata ---
264
265 #[test]
266 fn sub_metadata_valid() {
267 let s = UserId::new(); let p = ProjectId::new(); let t = SubscriptionTierId::new();
268 let m = meta_of(&[
269 ("subscriber_id", &s.to_string()),
270 ("project_id", &p.to_string()),
271 ("tier_id", &t.to_string()),
272 ("checkout_type", "subscription"),
273 ]);
274 let r = SubscriptionCheckoutMetadata::from_metadata(Some(&m)).unwrap();
275 assert_eq!(r.subscriber_id, s);
276 assert_eq!(r.project_id, p);
277 assert_eq!(r.tier_id, t);
278 }
279
280 #[test]
281 fn sub_metadata_missing_tier_id() {
282 let m = meta_of(&[
283 ("subscriber_id", &UserId::new().to_string()),
284 ("project_id", &ProjectId::new().to_string()),
285 ]);
286 assert!(SubscriptionCheckoutMetadata::from_metadata(Some(&m)).is_err());
287 }
288
289 #[test]
290 fn sub_metadata_missing_all() {
291 assert!(SubscriptionCheckoutMetadata::from_metadata(None).is_err());
292 }
293
294 // --- is_*_checkout ---
295
296 #[test]
297 fn is_subscription_checkout_true() {
298 let m = meta_of(&[("checkout_type", "subscription")]);
299 assert!(is_subscription_checkout(Some(&m)));
300 }
301
302 #[test]
303 fn is_subscription_checkout_false_no_metadata() {
304 assert!(!is_subscription_checkout(None));
305 }
306
307 #[test]
308 fn is_subscription_checkout_false_purchase() {
309 let m = meta_of(&[("buyer_id", &UserId::new().to_string())]);
310 assert!(!is_subscription_checkout(Some(&m)));
311 }
312
313 // --- CartCheckoutMetadata ---
314
315 #[test]
316 fn cart_metadata_valid() {
317 let b = UserId::new(); let s = UserId::new();
318 let m = meta_of(&[
319 ("checkout_type", "cart"),
320 ("buyer_id", &b.to_string()),
321 ("seller_id", &s.to_string()),
322 ]);
323 let r = CartCheckoutMetadata::from_metadata(Some(&m)).unwrap();
324 assert_eq!(r.buyer_id, b);
325 assert_eq!(r.seller_id, s);
326 }
327
328 #[test]
329 fn cart_metadata_missing_buyer_id() {
330 let m = meta_of(&[("checkout_type", "cart"), ("seller_id", &UserId::new().to_string())]);
331 assert!(CartCheckoutMetadata::from_metadata(Some(&m)).is_err());
332 }
333
334 #[test]
335 fn cart_metadata_missing_metadata() {
336 assert!(CartCheckoutMetadata::from_metadata(None).is_err());
337 }
338
339 // --- GuestCheckoutMetadata ---
340
341 #[test]
342 fn guest_metadata_valid_with_promo() {
343 let s = UserId::new(); let i = ItemId::new(); let p = PromoCodeId::new();
344 let m = meta_of(&[
345 ("checkout_type", "guest"),
346 ("seller_id", &s.to_string()),
347 ("item_id", &i.to_string()),
348 ("promo_code_id", &p.to_string()),
349 ]);
350 let r = GuestCheckoutMetadata::from_metadata(Some(&m)).unwrap();
351 assert_eq!(r.seller_id, s);
352 assert_eq!(r.item_id, i);
353 assert_eq!(r.promo_code_id, Some(p));
354 }
355
356 #[test]
357 fn guest_metadata_valid_without_promo() {
358 let s = UserId::new(); let i = ItemId::new();
359 let m = meta_of(&[
360 ("checkout_type", "guest"),
361 ("seller_id", &s.to_string()),
362 ("item_id", &i.to_string()),
363 ]);
364 let r = GuestCheckoutMetadata::from_metadata(Some(&m)).unwrap();
365 assert_eq!(r.seller_id, s);
366 assert_eq!(r.item_id, i);
367 assert_eq!(r.promo_code_id, None);
368 }
369
370 #[test]
371 fn guest_metadata_missing_seller_id() {
372 let m = meta_of(&[("checkout_type", "guest"), ("item_id", &ItemId::new().to_string())]);
373 assert!(GuestCheckoutMetadata::from_metadata(Some(&m)).is_err());
374 }
375
376 // --- TipCheckoutMetadata ---
377
378 #[test]
379 fn tip_metadata_valid_full() {
380 let t = UserId::new(); let r2 = UserId::new(); let p = ProjectId::new();
381 let m = meta_of(&[
382 ("checkout_type", "tip"),
383 ("tipper_id", &t.to_string()),
384 ("recipient_id", &r2.to_string()),
385 ("project_id", &p.to_string()),
386 ("message", "Great work!"),
387 ]);
388 let r = TipCheckoutMetadata::from_metadata(Some(&m)).unwrap();
389 assert_eq!(r.tipper_id, t);
390 assert_eq!(r.recipient_id, r2);
391 assert_eq!(r.project_id, Some(p));
392 assert_eq!(r.message.as_deref(), Some("Great work!"));
393 }
394
395 #[test]
396 fn tip_metadata_valid_minimal() {
397 let t = UserId::new(); let r2 = UserId::new();
398 let m = meta_of(&[
399 ("checkout_type", "tip"),
400 ("tipper_id", &t.to_string()),
401 ("recipient_id", &r2.to_string()),
402 ]);
403 let r = TipCheckoutMetadata::from_metadata(Some(&m)).unwrap();
404 assert_eq!(r.project_id, None);
405 assert_eq!(r.message, None);
406 }
407
408 #[test]
409 fn tip_metadata_missing_tipper_id() {
410 let m = meta_of(&[("checkout_type", "tip"), ("recipient_id", &UserId::new().to_string())]);
411 assert!(TipCheckoutMetadata::from_metadata(Some(&m)).is_err());
412 }
413
414 // --- is_* combinations ---
415
416 #[test]
417 fn is_cart_checkout_true() {
418 let m = meta_of(&[("checkout_type", "cart")]);
419 assert!(is_cart_checkout(Some(&m)));
420 }
421
422 #[test]
423 fn is_cart_checkout_false_for_item() {
424 let m = meta_of(&[("buyer_id", &UserId::new().to_string())]);
425 assert!(!is_cart_checkout(Some(&m)));
426 }
427
428 #[test]
429 fn is_guest_checkout_true() {
430 let m = meta_of(&[("checkout_type", "guest")]);
431 assert!(is_guest_checkout(Some(&m)));
432 }
433
434 #[test]
435 fn is_tip_checkout_true() {
436 let m = meta_of(&[("checkout_type", "tip")]);
437 assert!(is_tip_checkout(Some(&m)));
438 }
439
440 #[test]
441 fn is_fan_plus_checkout_true() {
442 let m = meta_of(&[("checkout_type", "fan_plus")]);
443 assert!(is_fan_plus_checkout(Some(&m)));
444 }
445
446 #[test]
447 fn is_creator_tier_checkout_true() {
448 let m = meta_of(&[("checkout_type", "creator_tier")]);
449 assert!(is_creator_tier_checkout(Some(&m)));
450 }
451
452 // --- FanPlusCheckoutMetadata ---
453
454 #[test]
455 fn fan_plus_metadata_valid() {
456 let u = UserId::new();
457 let m = meta_of(&[("checkout_type", "fan_plus"), ("user_id", &u.to_string())]);
458 let r = FanPlusCheckoutMetadata::from_metadata(Some(&m)).unwrap();
459 assert_eq!(r.user_id, u);
460 }
461
462 #[test]
463 fn fan_plus_metadata_missing_user_id() {
464 let m = meta_of(&[("checkout_type", "fan_plus")]);
465 let err = FanPlusCheckoutMetadata::from_metadata(Some(&m)).unwrap_err();
466 assert!(format!("{err:?}").contains("user_id"));
467 }
468
469 // --- CreatorTierCheckoutMetadata ---
470
471 #[test]
472 fn creator_tier_metadata_valid() {
473 let u = UserId::new();
474 let m = meta_of(&[
475 ("checkout_type", "creator_tier"),
476 ("user_id", &u.to_string()),
477 ("tier", "everything"),
478 ]);
479 let r = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap();
480 assert_eq!(r.user_id, u);
481 assert_eq!(r.tier, "everything");
482 }
483
484 #[test]
485 fn creator_tier_metadata_preserves_tier_string_verbatim() {
486 let u = UserId::new();
487 let m = meta_of(&[("user_id", &u.to_string()), ("tier", "Big Files")]);
488 let r = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap();
489 assert_eq!(r.tier, "Big Files");
490 }
491
492 #[test]
493 fn creator_tier_metadata_missing_tier() {
494 let m = meta_of(&[("user_id", &UserId::new().to_string())]);
495 let err = CreatorTierCheckoutMetadata::from_metadata(Some(&m)).unwrap_err();
496 assert!(format!("{err:?}").contains("tier"));
497 }
498
499 // --- get_checkout_type ---
500
501 #[test]
502 fn get_checkout_type_recognises_each_variant() {
503 for (s, expected) in [
504 ("tip", CheckoutType::Tip),
505 ("fan_plus", CheckoutType::FanPlus),
506 ("creator_tier", CheckoutType::CreatorTier),
507 ("subscription", CheckoutType::Subscription),
508 ("guest", CheckoutType::Guest),
509 ("cart", CheckoutType::Cart),
510 ] {
511 let m = meta_of(&[("checkout_type", s)]);
512 assert_eq!(get_checkout_type(Some(&m)), Some(expected));
513 }
514 }
515
516 #[test]
517 fn get_checkout_type_unknown_string_is_none() {
518 let m = meta_of(&[("checkout_type", "not-a-known-type")]);
519 assert_eq!(get_checkout_type(Some(&m)), None);
520 }
521
522 #[test]
523 fn get_checkout_type_missing_field_is_none() {
524 let m = HashMap::new();
525 assert_eq!(get_checkout_type(Some(&m)), None);
526 }
527
528 #[test]
529 fn is_predicates_dont_cross_match() {
530 let m = meta_of(&[("checkout_type", "tip")]);
531 let meta = Some(&m);
532 assert!(is_tip_checkout(meta));
533 assert!(!is_fan_plus_checkout(meta));
534 assert!(!is_creator_tier_checkout(meta));
535 assert!(!is_subscription_checkout(meta));
536 assert!(!is_guest_checkout(meta));
537 assert!(!is_cart_checkout(meta));
538 }
539 }
540