max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 files changed,
+966 insertions,
-13 deletions
| @@ -53,6 +53,9 @@ | |||
| 53 | 53 | ||
| 54 | 54 | #[error("Conflict: {0}")] | |
| 55 | 55 | Conflict(String), | |
| 56 | + | ||
| 57 | + | #[error("Payment required: {0}")] | |
| 58 | + | PaymentRequired(String), | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 58 | 61 | impl AppError { | |
| @@ -72,6 +75,7 @@ | |||
| 72 | 75 | AppError::MalwareDetected(_) => "malware_detected", | |
| 73 | 76 | AppError::ServiceUnavailable(_) => "service_unavailable", | |
| 74 | 77 | AppError::Conflict(_) => "conflict", | |
| 78 | + | AppError::PaymentRequired(_) => "payment_required", | |
| 75 | 79 | } | |
| 76 | 80 | } | |
| 77 | 81 | ||
| @@ -91,6 +95,7 @@ | |||
| 91 | 95 | AppError::MalwareDetected(_) => StatusCode::UNPROCESSABLE_ENTITY, | |
| 92 | 96 | AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, | |
| 93 | 97 | AppError::Conflict(_) => StatusCode::CONFLICT, | |
| 98 | + | AppError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED, | |
| 94 | 99 | } | |
| 95 | 100 | } | |
| 96 | 101 | ||
| @@ -109,6 +114,7 @@ | |||
| 109 | 114 | } | |
| 110 | 115 | AppError::ServiceUnavailable(msg) => msg.clone(), | |
| 111 | 116 | AppError::Conflict(msg) => msg.clone(), | |
| 117 | + | AppError::PaymentRequired(msg) => msg.clone(), | |
| 112 | 118 | AppError::Database(_) | AppError::Internal(_) | AppError::Storage(_) => { | |
| 113 | 119 | "Something went wrong. Please try again later.".to_string() | |
| 114 | 120 | } |
| @@ -538,6 +538,96 @@ | |||
| 538 | 538 | } | |
| 539 | 539 | } | |
| 540 | 540 | ||
| 541 | + | // ── App Sync Tiers ── | |
| 542 | + | ||
| 543 | + | /// Subscription tier for app-level cloud sync (GO, BB, AF). | |
| 544 | + | /// GO and BB use `Standard` (single tier). AF uses Light/Standard/Large for blob storage. | |
| 545 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 546 | + | #[serde(rename_all = "snake_case")] | |
| 547 | + | pub enum AppSyncTier { | |
| 548 | + | /// GO/BB single tier, or AF metadata-only (no blob storage) | |
| 549 | + | Standard, | |
| 550 | + | /// AF blob: 10 GB | |
| 551 | + | Light, | |
| 552 | + | /// AF blob: 50 GB (also the default for GO/BB) | |
| 553 | + | Large, | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | impl_str_enum!(AppSyncTier { | |
| 557 | + | Standard => "standard", | |
| 558 | + | Light => "light", | |
| 559 | + | Large => "large", | |
| 560 | + | }); | |
| 561 | + | ||
| 562 | + | impl AppSyncTier { | |
| 563 | + | /// Human-readable label for display. | |
| 564 | + | pub fn label(&self) -> &'static str { | |
| 565 | + | match self { | |
| 566 | + | Self::Standard => "Standard", | |
| 567 | + | Self::Light => "Light", | |
| 568 | + | Self::Large => "Large", | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | /// Blob storage limit in bytes, if this tier includes blob storage. | |
| 573 | + | /// Returns None for tiers that don't gate blob storage (GO/BB Standard). | |
| 574 | + | pub fn blob_storage_bytes(&self) -> Option<i64> { | |
| 575 | + | match self { | |
| 576 | + | Self::Light => Some(10 * 1024 * 1024 * 1024), // 10 GB | |
| 577 | + | Self::Standard => Some(50 * 1024 * 1024 * 1024), // 50 GB | |
| 578 | + | Self::Large => Some(200 * 1024 * 1024 * 1024), // 200 GB | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | /// Monthly price in cents for a given app. Returns None if tier is not valid for the app. | |
| 583 | + | pub fn monthly_price_cents(&self, app_name: &str) -> Option<i64> { | |
| 584 | + | match app_name.to_lowercase().as_str() { | |
| 585 | + | "goingson" => match self { | |
| 586 | + | Self::Standard => Some(200), // $2/mo | |
| 587 | + | _ => None, | |
| 588 | + | }, | |
| 589 | + | "balanced_breakfast" | "balanced breakfast" => match self { | |
| 590 | + | Self::Standard => Some(100), // $1/mo | |
| 591 | + | _ => None, | |
| 592 | + | }, | |
| 593 | + | "audiofiles" => match self { | |
| 594 | + | Self::Light => Some(100), // $1/mo | |
| 595 | + | Self::Standard => Some(300), // $3/mo | |
| 596 | + | Self::Large => Some(800), // $8/mo | |
| 597 | + | }, | |
| 598 | + | _ => None, | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | /// Annual price in cents for a given app. Returns None if tier is not valid for the app. | |
| 603 | + | pub fn annual_price_cents(&self, app_name: &str) -> Option<i64> { | |
| 604 | + | match app_name.to_lowercase().as_str() { | |
| 605 | + | "goingson" => match self { | |
| 606 | + | Self::Standard => Some(1500), // $15/yr | |
| 607 | + | _ => None, | |
| 608 | + | }, | |
| 609 | + | "balanced_breakfast" | "balanced breakfast" => match self { | |
| 610 | + | Self::Standard => Some(800), // $8/yr | |
| 611 | + | _ => None, | |
| 612 | + | }, | |
| 613 | + | "audiofiles" => match self { | |
| 614 | + | Self::Light => Some(1000), // $10/yr | |
| 615 | + | Self::Standard => Some(3000), // $30/yr | |
| 616 | + | Self::Large => Some(8000), // $80/yr | |
| 617 | + | }, | |
| 618 | + | _ => None, | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | /// Product name for Stripe checkout display. | |
| 623 | + | pub fn product_name(&self, app_name: &str) -> String { | |
| 624 | + | match app_name.to_lowercase().as_str() { | |
| 625 | + | "audiofiles" => format!("audiofiles Cloud Sync — {}", self.label()), | |
| 626 | + | _ => format!("{app_name} Cloud Sync"), | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | ||
| 541 | 631 | // ── AI Tiers ── | |
| 542 | 632 | ||
| 543 | 633 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| @@ -929,6 +1019,7 @@ | |||
| 929 | 1019 | FanPlus, | |
| 930 | 1020 | CreatorTier, | |
| 931 | 1021 | Cart, | |
| 1022 | + | AppSync, | |
| 932 | 1023 | } | |
| 933 | 1024 | ||
| 934 | 1025 | impl_str_enum!(CheckoutType { | |
| @@ -938,6 +1029,7 @@ | |||
| 938 | 1029 | FanPlus => "fan_plus", | |
| 939 | 1030 | CreatorTier => "creator_tier", | |
| 940 | 1031 | Cart => "cart", | |
| 1032 | + | AppSync => "app_sync", | |
| 941 | 1033 | }); | |
| 942 | 1034 | ||
| 943 | 1035 | impl ModerationActionType { | |
| @@ -1141,6 +1233,27 @@ | |||
| 1141 | 1233 | assert!(CreatorTier::Everything.allows_file_uploads()); | |
| 1142 | 1234 | } | |
| 1143 | 1235 | ||
| 1236 | + | #[test] | |
| 1237 | + | fn app_sync_tier_round_trip() { | |
| 1238 | + | assert_eq!(AppSyncTier::Standard.to_string(), "standard"); | |
| 1239 | + | assert_eq!("light".parse::<AppSyncTier>().unwrap(), AppSyncTier::Light); | |
| 1240 | + | assert_eq!("large".parse::<AppSyncTier>().unwrap(), AppSyncTier::Large); | |
| 1241 | + | assert!("bogus".parse::<AppSyncTier>().is_err()); | |
| 1242 | + | } | |
| 1243 | + | ||
| 1244 | + | #[test] | |
| 1245 | + | fn app_sync_tier_storage() { | |
| 1246 | + | assert_eq!(AppSyncTier::Light.blob_storage_bytes(), Some(10 * 1024 * 1024 * 1024)); | |
| 1247 | + | assert_eq!(AppSyncTier::Standard.blob_storage_bytes(), Some(50 * 1024 * 1024 * 1024)); | |
| 1248 | + | assert_eq!(AppSyncTier::Large.blob_storage_bytes(), Some(200 * 1024 * 1024 * 1024)); | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | #[test] | |
| 1252 | + | fn checkout_type_app_sync() { | |
| 1253 | + | assert_eq!(CheckoutType::AppSync.to_string(), "app_sync"); | |
| 1254 | + | assert_eq!("app_sync".parse::<CheckoutType>().unwrap(), CheckoutType::AppSync); | |
| 1255 | + | } | |
| 1256 | + | ||
| 1144 | 1257 | #[test] | |
| 1145 | 1258 | fn project_feature_round_trip() { | |
| 1146 | 1259 | assert_eq!(ProjectFeature::Audio.to_string(), "audio"); |
| @@ -65,6 +65,7 @@ | |||
| 65 | 65 | pub(crate) mod wishlists; | |
| 66 | 66 | pub(crate) mod cart; | |
| 67 | 67 | pub(crate) mod page_views; | |
| 68 | + | pub(crate) mod app_sync; | |
| 68 | 69 | ||
| 69 | 70 | pub use id_types::*; | |
| 70 | 71 | pub use validated_types::*; |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData, | |
| 7 | 7 | CreateCheckoutSessionLineItemsPriceDataProductData, Currency, | |
| 8 | 8 | }; | |
| 9 | - | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; | |
| 9 | + | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; | |
| 10 | 10 | use crate::error::{AppError, Result}; | |
| 11 | 11 | use super::StripeClient; | |
| 12 | 12 | ||
| @@ -485,6 +485,77 @@ | |||
| 485 | 485 | ||
| 486 | 486 | Ok(session) | |
| 487 | 487 | } | |
| 488 | + | ||
| 489 | + | /// Create a Checkout Session for an app sync subscription on MNW's own Stripe account. | |
| 490 | + | /// Uses inline price_data with recurring — no pre-created Stripe products needed. | |
| 491 | + | #[tracing::instrument(skip_all, name = "payments::create_app_sync_checkout_session")] | |
| 492 | + | pub async fn create_app_sync_checkout_session( | |
| 493 | + | &self, | |
| 494 | + | params: &AppSyncCheckoutParams<'_>, | |
| 495 | + | ) -> Result<CheckoutSession> { | |
| 496 | + | use stripe::CreateCheckoutSessionLineItemsPriceDataRecurring; | |
| 497 | + | use stripe::CreateCheckoutSessionLineItemsPriceDataRecurringInterval; | |
| 498 | + | ||
| 499 | + | let recurring_interval = match params.interval { | |
| 500 | + | "year" => CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Year, | |
| 501 | + | _ => CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month, | |
| 502 | + | }; | |
| 503 | + | ||
| 504 | + | let mut checkout_params = CreateCheckoutSession::new(); | |
| 505 | + | checkout_params.mode = Some(CheckoutSessionMode::Subscription); | |
| 506 | + | checkout_params.success_url = Some(params.success_url); | |
| 507 | + | checkout_params.cancel_url = Some(params.cancel_url); | |
| 508 | + | ||
| 509 | + | let line_item = CreateCheckoutSessionLineItems { | |
| 510 | + | price_data: Some(CreateCheckoutSessionLineItemsPriceData { | |
| 511 | + | currency: Currency::USD, | |
| 512 | + | product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData { | |
| 513 | + | name: params.product_name.to_string(), | |
| 514 | + | ..Default::default() | |
| 515 | + | }), | |
| 516 | + | unit_amount: Some(params.price_cents), | |
| 517 | + | recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring { | |
| 518 | + | interval: recurring_interval, | |
| 519 | + | ..Default::default() | |
| 520 | + | }), | |
| 521 | + | ..Default::default() | |
| 522 | + | }), | |
| 523 | + | quantity: Some(1), | |
| 524 | + | ..Default::default() | |
| 525 | + | }; | |
| 526 | + | checkout_params.line_items = Some(vec![line_item]); | |
| 527 | + | ||
| 528 | + | let mut metadata = std::collections::HashMap::new(); | |
| 529 | + | metadata.insert("checkout_type".to_string(), CheckoutType::AppSync.to_string()); | |
| 530 | + | metadata.insert("user_id".to_string(), params.user_id.to_string()); | |
| 531 | + | metadata.insert("app_id".to_string(), params.app_id.to_string()); | |
| 532 | + | metadata.insert("tier".to_string(), params.tier.to_string()); | |
| 533 | + | metadata.insert("app_name".to_string(), params.app_name.to_string()); | |
| 534 | + | checkout_params.metadata = Some(metadata); | |
| 535 | + | ||
| 536 | + | let session = CheckoutSession::create(&self.client, checkout_params) | |
| 537 | + | .await | |
| 538 | + | .map_err(|e| { | |
| 539 | + | tracing::error!(error = ?e, "failed to create app sync checkout session"); | |
| 540 | + | AppError::BadRequest("Failed to create app sync checkout".to_string()) | |
| 541 | + | })?; | |
| 542 | + | ||
| 543 | + | Ok(session) | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | /// Parameters for creating an app sync subscription Checkout Session (inline pricing). | |
| 548 | + | pub struct AppSyncCheckoutParams<'a> { | |
| 549 | + | pub product_name: &'a str, | |
| 550 | + | pub price_cents: i64, | |
| 551 | + | /// "month" or "year" | |
| 552 | + | pub interval: &'a str, | |
| 553 | + | pub user_id: UserId, | |
| 554 | + | pub app_id: SyncAppId, | |
| 555 | + | pub app_name: &'a str, | |
| 556 | + | pub tier: &'a str, | |
| 557 | + | pub success_url: &'a str, | |
| 558 | + | pub cancel_url: &'a str, | |
| 488 | 559 | } | |
| 489 | 560 | ||
| 490 | 561 | // ── Metadata types ── | |
| @@ -673,6 +744,45 @@ | |||
| 673 | 744 | } | |
| 674 | 745 | } | |
| 675 | 746 | ||
| 747 | + | /// Parsed metadata from an app sync checkout session. | |
| 748 | + | #[derive(Debug)] | |
| 749 | + | pub struct AppSyncCheckoutMetadata { | |
| 750 | + | pub user_id: UserId, | |
| 751 | + | pub app_id: SyncAppId, | |
| 752 | + | pub tier: String, | |
| 753 | + | pub app_name: String, | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | impl AppSyncCheckoutMetadata { | |
| 757 | + | /// Extract app sync metadata from a checkout session. | |
| 758 | + | pub fn from_session(session: &CheckoutSession) -> Result<Self> { | |
| 759 | + | let metadata = session.metadata.as_ref() | |
| 760 | + | .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?; | |
| 761 | + | ||
| 762 | + | let user_id: UserId = metadata.get("user_id") | |
| 763 | + | .ok_or_else(|| AppError::BadRequest("Missing user_id in metadata".to_string()))? | |
| 764 | + | .parse::<uuid::Uuid>() | |
| 765 | + | .map(UserId::from) | |
| 766 | + | .map_err(|_| AppError::BadRequest("Invalid user_id format".to_string()))?; | |
| 767 | + | ||
| 768 | + | let app_id: SyncAppId = metadata.get("app_id") | |
| 769 | + | .ok_or_else(|| AppError::BadRequest("Missing app_id in metadata".to_string()))? | |
| 770 | + | .parse::<uuid::Uuid>() | |
| 771 | + | .map(SyncAppId::from) | |
| 772 | + | .map_err(|_| AppError::BadRequest("Invalid app_id format".to_string()))?; | |
| 773 | + | ||
| 774 | + | let tier = metadata.get("tier") | |
| 775 | + | .ok_or_else(|| AppError::BadRequest("Missing tier in metadata".to_string()))? | |
| 776 | + | .clone(); | |
| 777 | + | ||
| 778 | + | let app_name = metadata.get("app_name") | |
| 779 | + | .ok_or_else(|| AppError::BadRequest("Missing app_name in metadata".to_string()))? | |
| 780 | + | .clone(); | |
| 781 | + | ||
| 782 | + | Ok(AppSyncCheckoutMetadata { user_id, app_id, tier, app_name }) | |
| 783 | + | } | |
| 784 | + | } | |
| 785 | + | ||
| 676 | 786 | /// Extract the checkout type from a Stripe session's metadata. | |
| 677 | 787 | pub fn get_checkout_type(session: &CheckoutSession) -> Option<CheckoutType> { | |
| 678 | 788 | session.metadata.as_ref() | |
| @@ -705,6 +815,11 @@ | |||
| 705 | 815 | get_checkout_type(session) == Some(CheckoutType::Guest) | |
| 706 | 816 | } | |
| 707 | 817 | ||
| 818 | + | /// Check if a checkout session is for an app sync subscription. | |
| 819 | + | pub fn is_app_sync_checkout(session: &CheckoutSession) -> bool { | |
| 820 | + | get_checkout_type(session) == Some(CheckoutType::AppSync) | |
| 821 | + | } | |
| 822 | + | ||
| 708 | 823 | /// Check if a checkout session is a cart (multi-item) checkout. | |
| 709 | 824 | pub fn is_cart_checkout(session: &CheckoutSession) -> bool { | |
| 710 | 825 | get_checkout_type(session) == Some(CheckoutType::Cart) |