Skip to main content

max / makenotwork

Add app sync subscription system (migration 098, server gate, SDK methods) Server: app_sync_subscriptions table, AppSyncTier enum, sync gate (402 for GO/BB metadata, 402 for AF blobs), inline Stripe checkout with price_data (no pre-created products), webhook handlers for subscription lifecycle. SyncKit client: get_subscription_status() and create_subscription_checkout() methods with new /api/v1/sync/subscription endpoints.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:15 UTC
Commit: 0cc8cf72cd5196382b56bbbe0dcf15eb3390c90f
Parent: 85c9938
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)