max / makenotwork
22 files changed,
+966 insertions,
-13 deletions
| @@ -80,12 +80,13 @@ MNW may eventually offer creators a cloud storage budget as part of their tier ( | |||
| 80 | 80 | ||
| 81 | 81 | ## Implementation | |
| 82 | 82 | ||
| 83 | - | - [ ] Stripe products and prices for BB sync (monthly + annual) | |
| 84 | - | - [ ] Stripe products and prices for GO sync (monthly + annual) | |
| 85 | - | - [ ] Stripe products and prices for AF blob tiers (monthly + annual, 3 tiers) | |
| 86 | - | - [ ] Sync gate in BB: check subscription status before enabling sync | |
| 87 | - | - [ ] Sync gate in GO: check subscription status before enabling sync | |
| 88 | - | - [ ] Sync gate in AF: metadata sync ungated, blob sync checks tier subscription | |
| 89 | - | - [ ] Annual renewal handling (Stripe webhook) | |
| 83 | + | - [x] Stripe pricing for BB sync — inline price_data ($1/mo, $8/yr), migration 098 | |
| 84 | + | - [x] Stripe pricing for GO sync — inline price_data ($2/mo, $15/yr) | |
| 85 | + | - [x] Stripe pricing for AF blob tiers — inline price_data (3 tiers, monthly + annual) | |
| 86 | + | - [x] Sync gate in BB: server 402 on push/pull, scheduler 1h backoff, settings subscribe UI | |
| 87 | + | - [x] Sync gate in GO: server 402 on push/pull, scheduler 1h backoff, settings subscribe UI | |
| 88 | + | - [x] Sync gate in AF: metadata ungated, server 402 on blob endpoints, egui tier selector + storage bar | |
| 89 | + | - [x] Webhook handling: checkout.session.completed creates record, subscription.updated/deleted/invoice events handled | |
| 90 | 90 | - [ ] Upgrade/downgrade flow for AF tiers | |
| 91 | 91 | - [ ] Cancellation: sync stops at end of billing period, data retained 30 days | |
| 92 | + | - [ ] Test end-to-end against live Stripe |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | -- App-level sync subscriptions (GO, BB, AF cloud sync). | |
| 2 | + | -- One subscription per user per app. Payment goes to MNW's own Stripe account. | |
| 3 | + | CREATE TABLE app_sync_subscriptions ( | |
| 4 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 5 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 6 | + | app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE, | |
| 7 | + | stripe_subscription_id TEXT NOT NULL UNIQUE, | |
| 8 | + | stripe_customer_id TEXT NOT NULL, | |
| 9 | + | -- Tier: 'standard' for GO/BB (single tier), 'light'/'standard'/'large' for AF blob tiers | |
| 10 | + | tier TEXT NOT NULL DEFAULT 'standard', | |
| 11 | + | status TEXT NOT NULL DEFAULT 'active', | |
| 12 | + | -- AF blob storage: tracked in bytes, NULL for non-blob apps | |
| 13 | + | storage_limit_bytes BIGINT, | |
| 14 | + | current_period_start TIMESTAMPTZ, | |
| 15 | + | current_period_end TIMESTAMPTZ, | |
| 16 | + | canceled_at TIMESTAMPTZ, | |
| 17 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 18 | + | ); | |
| 19 | + | ||
| 20 | + | -- One active subscription per user per app | |
| 21 | + | CREATE UNIQUE INDEX idx_app_sync_subs_user_app ON app_sync_subscriptions(user_id, app_id); | |
| 22 | + | CREATE INDEX idx_app_sync_subs_stripe ON app_sync_subscriptions(stripe_subscription_id); |
| @@ -0,0 +1,208 @@ | |||
| 1 | + | //! App-level sync subscription queries (GO, BB, AF cloud sync). | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use sqlx::PgPool; | |
| 5 | + | ||
| 6 | + | use super::enums::{AppSyncTier, SubscriptionStatus}; | |
| 7 | + | use super::id_types::*; | |
| 8 | + | use super::models::DbAppSyncSubscription; | |
| 9 | + | use crate::error::Result; | |
| 10 | + | ||
| 11 | + | /// Create a new app sync subscription. | |
| 12 | + | /// | |
| 13 | + | /// Uses ON CONFLICT DO NOTHING on (user_id, app_id) to handle duplicate | |
| 14 | + | /// webhook deliveries and concurrent subscription attempts. | |
| 15 | + | #[tracing::instrument(skip_all)] | |
| 16 | + | pub async fn create_app_sync_subscription<'e>( | |
| 17 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 18 | + | user_id: UserId, | |
| 19 | + | app_id: SyncAppId, | |
| 20 | + | stripe_subscription_id: &str, | |
| 21 | + | stripe_customer_id: &str, | |
| 22 | + | tier: AppSyncTier, | |
| 23 | + | storage_limit_bytes: Option<i64>, | |
| 24 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 25 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 26 | + | r#" | |
| 27 | + | INSERT INTO app_sync_subscriptions | |
| 28 | + | (user_id, app_id, stripe_subscription_id, stripe_customer_id, tier, storage_limit_bytes) | |
| 29 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 30 | + | ON CONFLICT (user_id, app_id) DO NOTHING | |
| 31 | + | RETURNING * | |
| 32 | + | "#, | |
| 33 | + | ) | |
| 34 | + | .bind(user_id) | |
| 35 | + | .bind(app_id) | |
| 36 | + | .bind(stripe_subscription_id) | |
| 37 | + | .bind(stripe_customer_id) | |
| 38 | + | .bind(tier) | |
| 39 | + | .bind(storage_limit_bytes) | |
| 40 | + | .fetch_optional(executor) | |
| 41 | + | .await?; | |
| 42 | + | ||
| 43 | + | Ok(sub) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Look up an app sync subscription by its Stripe subscription ID. | |
| 47 | + | #[tracing::instrument(skip_all)] | |
| 48 | + | pub async fn get_app_sync_sub_by_stripe_id( | |
| 49 | + | pool: &PgPool, | |
| 50 | + | stripe_subscription_id: &str, | |
| 51 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 52 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 53 | + | "SELECT * FROM app_sync_subscriptions WHERE stripe_subscription_id = $1", | |
| 54 | + | ) | |
| 55 | + | .bind(stripe_subscription_id) | |
| 56 | + | .fetch_optional(pool) | |
| 57 | + | .await?; | |
| 58 | + | ||
| 59 | + | Ok(sub) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Get a user's app sync subscription for a specific app (any status). | |
| 63 | + | #[tracing::instrument(skip_all)] | |
| 64 | + | pub async fn get_app_sync_sub( | |
| 65 | + | pool: &PgPool, | |
| 66 | + | user_id: UserId, | |
| 67 | + | app_id: SyncAppId, | |
| 68 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 69 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 70 | + | "SELECT * FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2", | |
| 71 | + | ) | |
| 72 | + | .bind(user_id) | |
| 73 | + | .bind(app_id) | |
| 74 | + | .fetch_optional(pool) | |
| 75 | + | .await?; | |
| 76 | + | ||
| 77 | + | Ok(sub) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Check whether a user has an active sync subscription for an app. | |
| 81 | + | #[tracing::instrument(skip_all)] | |
| 82 | + | pub async fn has_active_app_sync_sub( | |
| 83 | + | pool: &PgPool, | |
| 84 | + | user_id: UserId, | |
| 85 | + | app_id: SyncAppId, | |
| 86 | + | ) -> Result<bool> { | |
| 87 | + | let exists = sqlx::query_scalar::<_, bool>( | |
| 88 | + | "SELECT EXISTS(SELECT 1 FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2 AND status = 'active')", | |
| 89 | + | ) | |
| 90 | + | .bind(user_id) | |
| 91 | + | .bind(app_id) | |
| 92 | + | .fetch_one(pool) | |
| 93 | + | .await?; | |
| 94 | + | ||
| 95 | + | Ok(exists) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Get the blob storage limit for a user's active app sync subscription. | |
| 99 | + | /// Returns None if no active subscription or no storage limit set. | |
| 100 | + | #[tracing::instrument(skip_all)] | |
| 101 | + | pub async fn get_blob_storage_limit( | |
| 102 | + | pool: &PgPool, | |
| 103 | + | user_id: UserId, | |
| 104 | + | app_id: SyncAppId, | |
| 105 | + | ) -> Result<Option<i64>> { | |
| 106 | + | let limit = sqlx::query_scalar::<_, Option<i64>>( | |
| 107 | + | "SELECT storage_limit_bytes FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2 AND status = 'active'", | |
| 108 | + | ) | |
| 109 | + | .bind(user_id) | |
| 110 | + | .bind(app_id) | |
| 111 | + | .fetch_optional(pool) | |
| 112 | + | .await?; | |
| 113 | + | ||
| 114 | + | Ok(limit.flatten()) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Update the status of an app sync subscription. | |
| 118 | + | #[tracing::instrument(skip_all)] | |
| 119 | + | pub async fn update_app_sync_sub_status<'e>( | |
| 120 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 121 | + | stripe_subscription_id: &str, | |
| 122 | + | status: SubscriptionStatus, | |
| 123 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 124 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 125 | + | r#" | |
| 126 | + | UPDATE app_sync_subscriptions | |
| 127 | + | SET status = $2 | |
| 128 | + | WHERE stripe_subscription_id = $1 | |
| 129 | + | RETURNING * | |
| 130 | + | "#, | |
| 131 | + | ) | |
| 132 | + | .bind(stripe_subscription_id) | |
| 133 | + | .bind(status) | |
| 134 | + | .fetch_optional(executor) | |
| 135 | + | .await?; | |
| 136 | + | ||
| 137 | + | Ok(sub) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Update the billing period of an app sync subscription. | |
| 141 | + | #[tracing::instrument(skip_all)] | |
| 142 | + | pub async fn update_app_sync_sub_period<'e>( | |
| 143 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 144 | + | stripe_subscription_id: &str, | |
| 145 | + | start: DateTime<Utc>, | |
| 146 | + | end: DateTime<Utc>, | |
| 147 | + | ) -> Result<()> { | |
| 148 | + | sqlx::query( | |
| 149 | + | r#" | |
| 150 | + | UPDATE app_sync_subscriptions | |
| 151 | + | SET current_period_start = $2, current_period_end = $3 | |
| 152 | + | WHERE stripe_subscription_id = $1 | |
| 153 | + | "#, | |
| 154 | + | ) | |
| 155 | + | .bind(stripe_subscription_id) | |
| 156 | + | .bind(start) | |
| 157 | + | .bind(end) | |
| 158 | + | .execute(executor) | |
| 159 | + | .await?; | |
| 160 | + | ||
| 161 | + | Ok(()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Update the tier and storage limit (for AF tier upgrades/downgrades). | |
| 165 | + | #[tracing::instrument(skip_all)] | |
| 166 | + | pub async fn update_app_sync_sub_tier<'e>( | |
| 167 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 168 | + | stripe_subscription_id: &str, | |
| 169 | + | tier: AppSyncTier, | |
| 170 | + | storage_limit_bytes: Option<i64>, | |
| 171 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 172 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 173 | + | r#" | |
| 174 | + | UPDATE app_sync_subscriptions | |
| 175 | + | SET tier = $2, storage_limit_bytes = $3 | |
| 176 | + | WHERE stripe_subscription_id = $1 | |
| 177 | + | RETURNING * | |
| 178 | + | "#, | |
| 179 | + | ) | |
| 180 | + | .bind(stripe_subscription_id) | |
| 181 | + | .bind(tier) | |
| 182 | + | .bind(storage_limit_bytes) | |
| 183 | + | .fetch_optional(executor) | |
| 184 | + | .await?; | |
| 185 | + | ||
| 186 | + | Ok(sub) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Cancel an app sync subscription (set status + canceled_at). | |
| 190 | + | #[tracing::instrument(skip_all)] | |
| 191 | + | pub async fn cancel_app_sync_sub( | |
| 192 | + | pool: &PgPool, | |
| 193 | + | stripe_subscription_id: &str, | |
| 194 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 195 | + | let sub = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 196 | + | r#" | |
| 197 | + | UPDATE app_sync_subscriptions | |
| 198 | + | SET status = 'canceled', canceled_at = NOW() | |
| 199 | + | WHERE stripe_subscription_id = $1 | |
| 200 | + | RETURNING * | |
| 201 | + | "#, | |
| 202 | + | ) | |
| 203 | + | .bind(stripe_subscription_id) | |
| 204 | + | .fetch_optional(pool) | |
| 205 | + | .await?; | |
| 206 | + | ||
| 207 | + | Ok(sub) | |
| 208 | + | } |
| @@ -538,6 +538,96 @@ impl CreatorTier { | |||
| 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 @@ pub enum CheckoutType { | |||
| 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 @@ impl_str_enum!(CheckoutType { | |||
| 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 { | |
| @@ -1142,6 +1234,27 @@ mod tests { | |||
| 1142 | 1234 | } | |
| 1143 | 1235 | ||
| 1144 | 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 | + | ||
| 1257 | + | #[test] | |
| 1145 | 1258 | fn project_feature_round_trip() { | |
| 1146 | 1259 | assert_eq!(ProjectFeature::Audio.to_string(), "audio"); | |
| 1147 | 1260 | assert_eq!("downloads".parse::<ProjectFeature>().unwrap(), ProjectFeature::Downloads); |
| @@ -65,6 +65,7 @@ pub(crate) mod moderation; | |||
| 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::*; |
| @@ -112,6 +112,37 @@ pub struct DbSyncBlob { | |||
| 112 | 112 | pub uploaded_at: DateTime<Utc>, | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | + | // ── App sync subscription ── | |
| 116 | + | ||
| 117 | + | /// An app-level sync subscription (GO, BB, AF cloud sync). | |
| 118 | + | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 119 | + | pub struct DbAppSyncSubscription { | |
| 120 | + | /// Database primary key. | |
| 121 | + | pub id: uuid::Uuid, | |
| 122 | + | /// Subscribing user. | |
| 123 | + | pub user_id: UserId, | |
| 124 | + | /// Sync app this subscription covers. | |
| 125 | + | pub app_id: SyncAppId, | |
| 126 | + | /// Stripe subscription ID (e.g. `sub_...`). | |
| 127 | + | pub stripe_subscription_id: String, | |
| 128 | + | /// Stripe customer ID (e.g. `cus_...`). | |
| 129 | + | pub stripe_customer_id: String, | |
| 130 | + | /// Tier: "standard" for GO/BB, "light"/"standard"/"large" for AF blob tiers. | |
| 131 | + | pub tier: super::super::AppSyncTier, | |
| 132 | + | /// Subscription status (active, past_due, canceled, etc.). | |
| 133 | + | pub status: super::super::SubscriptionStatus, | |
| 134 | + | /// Blob storage limit in bytes (AF only, NULL for GO/BB). | |
| 135 | + | pub storage_limit_bytes: Option<i64>, | |
| 136 | + | /// Start of current billing period. | |
| 137 | + | pub current_period_start: Option<DateTime<Utc>>, | |
| 138 | + | /// End of current billing period. | |
| 139 | + | pub current_period_end: Option<DateTime<Utc>>, | |
| 140 | + | /// When the subscription was canceled. | |
| 141 | + | pub canceled_at: Option<DateTime<Utc>>, | |
| 142 | + | /// When the subscription was created. | |
| 143 | + | pub created_at: DateTime<Utc>, | |
| 144 | + | } | |
| 145 | + | ||
| 115 | 146 | // ── OTA models ── | |
| 116 | 147 | ||
| 117 | 148 | /// An OTA release for a sync app. |
| @@ -53,6 +53,9 @@ pub enum AppError { | |||
| 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 @@ impl AppError { | |||
| 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 @@ impl AppError { | |||
| 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 @@ impl AppError { | |||
| 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 | } |
| @@ -6,7 +6,7 @@ use stripe::{ | |||
| 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 @@ impl StripeClient { | |||
| 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 @@ impl TipCheckoutMetadata { | |||
| 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 @@ pub fn is_guest_checkout(session: &CheckoutSession) -> bool { | |||
| 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) |
| @@ -64,6 +64,7 @@ pub trait PaymentProvider: Send + Sync { | |||
| 64 | 64 | async fn create_tip_checkout_session(&self, params: &TipCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 65 | 65 | async fn create_fan_plus_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 66 | 66 | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 67 | + | async fn create_app_sync_checkout_session(&self, params: &AppSyncCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 67 | 68 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 68 | 69 | ||
| 69 | 70 | // Connect | |
| @@ -122,6 +123,11 @@ impl PaymentProvider for StripeClient { | |||
| 122 | 123 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | async fn create_app_sync_checkout_session(&self, params: &AppSyncCheckoutParams<'_>) -> crate::error::Result<CheckoutResult> { | |
| 127 | + | let session = StripeClient::create_app_sync_checkout_session(self, params).await?; | |
| 128 | + | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 129 | + | } | |
| 130 | + | ||
| 125 | 131 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult> { | |
| 126 | 132 | let session = StripeClient::create_cart_checkout_session(self, params).await?; | |
| 127 | 133 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) |
| @@ -120,6 +120,23 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 120 | 120 | return Ok(()); | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | + | // Check if this is an app sync subscription | |
| 124 | + | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 125 | + | if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) { | |
| 126 | + | let period_start = stripe_timestamp(start); | |
| 127 | + | let period_end = stripe_timestamp(end); | |
| 128 | + | db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 132 | + | &state.db, None, event_id, "invoice.payment_succeeded.app_sync", | |
| 133 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), | |
| 134 | + | ).await { | |
| 135 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 136 | + | } | |
| 137 | + | return Ok(()); | |
| 138 | + | } | |
| 139 | + | ||
| 123 | 140 | // Update period for creator subscriptions | |
| 124 | 141 | if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) { | |
| 125 | 142 | let period_start = stripe_timestamp(start); | |
| @@ -203,6 +220,19 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 203 | 220 | return Ok(()); | |
| 204 | 221 | } | |
| 205 | 222 | ||
| 223 | + | // Check if this is an app sync subscription | |
| 224 | + | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 225 | + | db::app_sync::update_app_sync_sub_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update app sync sub status to past_due")?; | |
| 226 | + | ||
| 227 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 228 | + | &state.db, None, event_id, "invoice.payment_failed.app_sync", | |
| 229 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id}), | |
| 230 | + | ).await { | |
| 231 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 232 | + | } | |
| 233 | + | return Ok(()); | |
| 234 | + | } | |
| 235 | + | ||
| 206 | 236 | let updated = db::subscriptions::update_subscription_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update subscription status to past_due")?; | |
| 207 | 237 | ||
| 208 | 238 | // Log event |
| @@ -475,6 +475,74 @@ pub(super) async fn handle_creator_tier_checkout_completed( | |||
| 475 | 475 | Ok(()) | |
| 476 | 476 | } | |
| 477 | 477 | ||
| 478 | + | /// Handle checkout.session.completed for app sync subscriptions | |
| 479 | + | #[tracing::instrument(skip_all, name = "stripe::handle_app_sync_checkout")] | |
| 480 | + | pub(super) async fn handle_app_sync_checkout_completed( | |
| 481 | + | state: &AppState, | |
| 482 | + | session: &stripe::CheckoutSession, | |
| 483 | + | event_id: &str, | |
| 484 | + | ) -> Result<()> { | |
| 485 | + | let session_id = session.id.to_string(); | |
| 486 | + | tracing::info!(session_id = %session_id, "processing completed app sync checkout"); | |
| 487 | + | ||
| 488 | + | let metadata = crate::payments::AppSyncCheckoutMetadata::from_session(session)?; | |
| 489 | + | let user_id = metadata.user_id; | |
| 490 | + | let app_id = metadata.app_id; | |
| 491 | + | let tier: db::AppSyncTier = metadata.tier.parse() | |
| 492 | + | .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?; | |
| 493 | + | ||
| 494 | + | let stripe_subscription_id = session.subscription | |
| 495 | + | .as_ref() | |
| 496 | + | .map(|s| s.id().to_string()) | |
| 497 | + | .ok_or_else(|| { | |
| 498 | + | tracing::error!("App sync checkout completed but no subscription ID on session"); | |
| 499 | + | AppError::BadRequest("Missing subscription ID on session".to_string()) | |
| 500 | + | })?; | |
| 501 | + | ||
| 502 | + | let stripe_customer_id = session.customer | |
| 503 | + | .as_ref() | |
| 504 | + | .map(|c| c.id().to_string()) | |
| 505 | + | .ok_or_else(|| { | |
| 506 | + | tracing::error!("App sync checkout completed but no customer ID on session"); | |
| 507 | + | AppError::BadRequest("Missing customer ID on session".to_string()) | |
| 508 | + | })?; | |
| 509 | + | ||
| 510 | + | let storage_limit_bytes = tier.blob_storage_bytes(); | |
| 511 | + | ||
| 512 | + | match db::app_sync::create_app_sync_subscription( | |
| 513 | + | &state.db, user_id, app_id, &stripe_subscription_id, &stripe_customer_id, | |
| 514 | + | tier, storage_limit_bytes, | |
| 515 | + | ).await | |
| 516 | + | .with_context(|| format!("create app sync subscription for user {user_id} app {app_id}"))? { | |
| 517 | + | Some(_sub) => { | |
| 518 | + | tracing::info!( | |
| 519 | + | user_id = %user_id, app_id = %app_id, tier = %tier, | |
| 520 | + | app_name = %metadata.app_name, | |
| 521 | + | "app sync subscription created" | |
| 522 | + | ); | |
| 523 | + | } | |
| 524 | + | None => { | |
| 525 | + | tracing::info!(user_id = %user_id, app_id = %app_id, "app sync subscription already exists, ignoring duplicate"); | |
| 526 | + | return Ok(()); | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 531 | + | &state.db, None, event_id, "checkout.session.completed.app_sync", | |
| 532 | + | &serde_json::json!({ | |
| 533 | + | "session_id": session_id, | |
| 534 | + | "stripe_subscription_id": stripe_subscription_id, | |
| 535 | + | "app_id": app_id, | |
| 536 | + | "tier": tier, | |
| 537 | + | "app_name": metadata.app_name, | |
| 538 | + | }), | |
| 539 | + | ).await { | |
| 540 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | Ok(()) | |
| 544 | + | } | |
| 545 | + | ||
| 478 | 546 | /// Handle checkout.session.completed for tips | |
| 479 | 547 | #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")] | |
| 480 | 548 | pub(super) async fn handle_tip_checkout_completed( |
| @@ -100,6 +100,8 @@ pub(crate) async fn process_webhook_event( | |||
| 100 | 100 | checkout::handle_fan_plus_checkout_completed(state, session, event_id).await?; | |
| 101 | 101 | } else if payments::is_creator_tier_checkout(session) { | |
| 102 | 102 | checkout::handle_creator_tier_checkout_completed(state, session, event_id).await?; | |
| 103 | + | } else if payments::is_app_sync_checkout(session) { | |
| 104 | + | checkout::handle_app_sync_checkout_completed(state, session, event_id).await?; | |
| 103 | 105 | } else if payments::is_tip_checkout(session) { | |
| 104 | 106 | checkout::handle_tip_checkout_completed(state, session, event_id).await?; | |
| 105 | 107 | } else if payments::is_subscription_checkout(session) { |
| @@ -59,6 +59,26 @@ pub(super) async fn handle_subscription_updated( | |||
| 59 | 59 | return Ok(()); | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | // Check if this is an app sync subscription | |
| 63 | + | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 64 | + | let status_str = sub.status.as_str(); | |
| 65 | + | let status: SubscriptionStatus = status_str.parse() | |
| 66 | + | .map_err(|_| AppError::BadRequest(format!("Unknown subscription status: {}", status_str)))?; | |
| 67 | + | db::app_sync::update_app_sync_sub_status(&state.db, &stripe_sub_id, status).await.context("update app sync sub status")?; | |
| 68 | + | ||
| 69 | + | let period_start = stripe_timestamp(sub.current_period_start); | |
| 70 | + | let period_end = stripe_timestamp(sub.current_period_end); | |
| 71 | + | db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?; | |
| 72 | + | ||
| 73 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 74 | + | &state.db, None, event_id, "customer.subscription.updated.app_sync", | |
| 75 | + | &serde_json::json!({"status": status_str}), | |
| 76 | + | ).await { | |
| 77 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 78 | + | } | |
| 79 | + | return Ok(()); | |
| 80 | + | } | |
| 81 | + | ||
| 62 | 82 | let status_str = sub.status.as_str(); | |
| 63 | 83 | let status: SubscriptionStatus = status_str.parse() | |
| 64 | 84 | .map_err(|_| { | |
| @@ -138,6 +158,24 @@ pub(super) async fn handle_subscription_deleted( | |||
| 138 | 158 | return Ok(()); | |
| 139 | 159 | } | |
| 140 | 160 | ||
| 161 | + | // Check if this is an app sync subscription | |
| 162 | + | if let Some(app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 163 | + | db::app_sync::cancel_app_sync_sub(&state.db, &stripe_sub_id).await.context("cancel app sync sub")?; | |
| 164 | + | ||
| 165 | + | tracing::info!( | |
| 166 | + | user_id = %app_sub.user_id, app_id = %app_sub.app_id, tier = %app_sub.tier, | |
| 167 | + | "app sync subscription canceled" | |
| 168 | + | ); | |
| 169 | + | ||
| 170 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 171 | + | &state.db, None, event_id, "customer.subscription.deleted.app_sync", | |
| 172 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id}), | |
| 173 | + | ).await { | |
| 174 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 175 | + | } | |
| 176 | + | return Ok(()); | |
| 177 | + | } | |
| 178 | + | ||
| 141 | 179 | let canceled = db::subscriptions::cancel_subscription(&state.db, &stripe_sub_id).await.context("cancel subscription")?; | |
| 142 | 180 | ||
| 143 | 181 | if let Some(ref db_sub) = canceled { |
| @@ -49,12 +49,22 @@ pub(super) async fn blob_upload_url( | |||
| 49 | 49 | ))); | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | // Enforce aggregate blob storage quota | |
| 52 | + | // Enforce blob storage quota from subscription tier | |
| 53 | + | let storage_limit = db::app_sync::get_blob_storage_limit( | |
| 54 | + | &state.db, sync_user.user_id, sync_user.app_id, | |
| 55 | + | ).await?; | |
| 56 | + | let storage_limit = storage_limit.unwrap_or(0); | |
| 57 | + | if storage_limit == 0 { | |
| 58 | + | return Err(AppError::PaymentRequired( | |
| 59 | + | "Blob sync requires an active subscription. Subscribe in your app settings.".to_string(), | |
| 60 | + | )); | |
| 61 | + | } | |
| 62 | + | ||
| 53 | 63 | let used = db::synckit::get_blob_storage_used(&state.db, sync_user.app_id, sync_user.user_id).await?; | |
| 54 | - | if used + req.size_bytes > constants::SYNCKIT_MAX_BLOB_STORAGE_BYTES { | |
| 64 | + | if used + req.size_bytes > storage_limit { | |
| 55 | 65 | return Err(AppError::BadRequest(format!( | |
| 56 | - | "Blob storage quota exceeded ({} GB limit)", | |
| 57 | - | constants::SYNCKIT_MAX_BLOB_STORAGE_BYTES / (1024 * 1024 * 1024) | |
| 66 | + | "Blob storage quota exceeded ({} GB limit). Upgrade your tier for more storage.", | |
| 67 | + | storage_limit / (1024 * 1024 * 1024) | |
| 58 | 68 | ))); | |
| 59 | 69 | } | |
| 60 | 70 |
| @@ -18,6 +18,7 @@ pub(crate) mod apps; | |||
| 18 | 18 | pub(crate) mod auth; | |
| 19 | 19 | pub(crate) mod blobs; | |
| 20 | 20 | mod subscribe; | |
| 21 | + | mod subscription; | |
| 21 | 22 | pub(crate) mod sync; | |
| 22 | 23 | ||
| 23 | 24 | use axum::{ | |
| @@ -290,6 +291,38 @@ pub(super) struct AppWithKey { | |||
| 290 | 291 | pub api_key: String, | |
| 291 | 292 | } | |
| 292 | 293 | ||
| 294 | + | // ── Subscription types ── | |
| 295 | + | ||
| 296 | + | #[derive(Deserialize)] | |
| 297 | + | pub(super) struct SubscriptionCheckoutRequest { | |
| 298 | + | /// Tier to subscribe to: "standard" for GO/BB, "light"/"standard"/"large" for AF | |
| 299 | + | pub tier: String, | |
| 300 | + | /// Billing interval: "monthly" or "annual" | |
| 301 | + | pub interval: String, | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[derive(Serialize)] | |
| 305 | + | pub(super) struct SubscriptionCheckoutResponse { | |
| 306 | + | /// Stripe Checkout URL to redirect the user to | |
| 307 | + | pub checkout_url: String, | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[derive(Serialize)] | |
| 311 | + | pub(super) struct SubscriptionStatusResponse { | |
| 312 | + | /// Whether the user has an active sync subscription for this app | |
| 313 | + | pub active: bool, | |
| 314 | + | /// Subscription tier (if active) | |
| 315 | + | pub tier: Option<String>, | |
| 316 | + | /// Subscription status string | |
| 317 | + | pub status: Option<String>, | |
| 318 | + | /// Blob storage limit in bytes (AF only) | |
| 319 | + | pub storage_limit_bytes: Option<i64>, | |
| 320 | + | /// Blob storage used in bytes | |
| 321 | + | pub storage_used_bytes: Option<i64>, | |
| 322 | + | /// End of current billing period | |
| 323 | + | pub current_period_end: Option<DateTime<Utc>>, | |
| 324 | + | } | |
| 325 | + | ||
| 293 | 326 | // ── Helper ── | |
| 294 | 327 | ||
| 295 | 328 | pub(super) fn generate_api_key() -> String { | |
| @@ -364,6 +397,10 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 364 | 397 | .route("/api/v1/sync/blobs/confirm", post(blobs::blob_confirm_upload)) | |
| 365 | 398 | .route("/api/sync/blobs/download", post(blobs::blob_download_url)) | |
| 366 | 399 | .route("/api/v1/sync/blobs/download", post(blobs::blob_download_url)) | |
| 400 | + | .route("/api/sync/subscription", get(subscription::get_subscription_status)) | |
| 401 | + | .route("/api/v1/sync/subscription", get(subscription::get_subscription_status)) | |
| 402 | + | .route("/api/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 403 | + | .route("/api/v1/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 367 | 404 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 368 | 405 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 369 | 406 | .route_layer(GovernorLayer { |
| @@ -0,0 +1,130 @@ | |||
| 1 | + | //! App sync subscription endpoints: status check and checkout session creation. | |
| 2 | + | ||
| 3 | + | use axum::{ | |
| 4 | + | extract::State, | |
| 5 | + | response::IntoResponse, | |
| 6 | + | Json, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use crate::{ | |
| 10 | + | db::{self, AppSyncTier}, | |
| 11 | + | error::{AppError, Result}, | |
| 12 | + | payments::AppSyncCheckoutParams, | |
| 13 | + | synckit_auth::SyncUser, | |
| 14 | + | AppState, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | use super::{SubscriptionCheckoutRequest, SubscriptionCheckoutResponse, SubscriptionStatusResponse}; | |
| 18 | + | ||
| 19 | + | /// Billing interval for inline price_data. | |
| 20 | + | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 21 | + | enum BillingInterval { | |
| 22 | + | Monthly, | |
| 23 | + | Annual, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// GET /api/v1/sync/subscription — check subscription status for this user+app. | |
| 27 | + | #[tracing::instrument(skip_all, name = "synckit::get_subscription_status")] | |
| 28 | + | pub(super) async fn get_subscription_status( | |
| 29 | + | State(state): State<AppState>, | |
| 30 | + | sync_user: SyncUser, | |
| 31 | + | ) -> Result<impl IntoResponse> { | |
| 32 | + | let sub = db::app_sync::get_app_sync_sub(&state.db, sync_user.user_id, sync_user.app_id).await?; | |
| 33 | + | ||
| 34 | + | let response = match sub { | |
| 35 | + | Some(sub) => { | |
| 36 | + | let storage_used = db::synckit::get_blob_storage_used( | |
| 37 | + | &state.db, sync_user.app_id, sync_user.user_id, | |
| 38 | + | ).await.unwrap_or(0); | |
| 39 | + | ||
| 40 | + | SubscriptionStatusResponse { | |
| 41 | + | active: sub.status == db::SubscriptionStatus::Active, | |
| 42 | + | tier: Some(sub.tier.to_string()), | |
| 43 | + | status: Some(sub.status.to_string()), | |
| 44 | + | storage_limit_bytes: sub.storage_limit_bytes, | |
| 45 | + | storage_used_bytes: Some(storage_used), | |
| 46 | + | current_period_end: sub.current_period_end, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | None => SubscriptionStatusResponse { | |
| 50 | + | active: false, | |
| 51 | + | tier: None, | |
| 52 | + | status: None, | |
| 53 | + | storage_limit_bytes: None, | |
| 54 | + | storage_used_bytes: None, | |
| 55 | + | current_period_end: None, | |
| 56 | + | }, | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | Ok(Json(response)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// POST /api/v1/sync/subscription/checkout — create a Stripe checkout session. | |
| 63 | + | /// | |
| 64 | + | /// The app opens the returned URL in a browser. After payment, the webhook | |
| 65 | + | /// creates the subscription record and sync becomes available. | |
| 66 | + | /// Uses inline price_data — no pre-created Stripe products needed. | |
| 67 | + | #[tracing::instrument(skip_all, name = "synckit::create_subscription_checkout")] | |
| 68 | + | pub(super) async fn create_checkout( | |
| 69 | + | State(state): State<AppState>, | |
| 70 | + | sync_user: SyncUser, | |
| 71 | + | Json(req): Json<SubscriptionCheckoutRequest>, | |
| 72 | + | ) -> Result<impl IntoResponse> { | |
| 73 | + | // Parse tier | |
| 74 | + | let tier: AppSyncTier = req.tier.parse() | |
| 75 | + | .map_err(|_| AppError::BadRequest("Invalid tier. Expected: standard, light, or large".to_string()))?; | |
| 76 | + | ||
| 77 | + | // Validate interval | |
| 78 | + | let interval = match req.interval.as_str() { | |
| 79 | + | "monthly" => BillingInterval::Monthly, | |
| 80 | + | "annual" => BillingInterval::Annual, | |
| 81 | + | _ => return Err(AppError::BadRequest("Invalid interval. Expected: monthly or annual".to_string())), | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | // Check not already subscribed | |
| 85 | + | if db::app_sync::has_active_app_sync_sub(&state.db, sync_user.user_id, sync_user.app_id).await? { | |
| 86 | + | return Err(AppError::BadRequest("Already subscribed. Manage your subscription at makenot.work/dashboard".to_string())); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Look up the app to get its name for pricing | |
| 90 | + | let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id) | |
| 91 | + | .await? | |
| 92 | + | .ok_or(AppError::Unauthorized)?; | |
| 93 | + | ||
| 94 | + | // Resolve price from hardcoded pricing table | |
| 95 | + | let price_cents = match interval { | |
| 96 | + | BillingInterval::Monthly => tier.monthly_price_cents(&app.name), | |
| 97 | + | BillingInterval::Annual => tier.annual_price_cents(&app.name), | |
| 98 | + | }; | |
| 99 | + | let price_cents = price_cents | |
| 100 | + | .ok_or_else(|| AppError::BadRequest(format!("Tier '{}' is not available for {}", tier, app.name)))?; | |
| 101 | + | ||
| 102 | + | let product_name = tier.product_name(&app.name); | |
| 103 | + | ||
| 104 | + | // Create checkout session with inline pricing | |
| 105 | + | let stripe = state.stripe.as_ref() | |
| 106 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 107 | + | ||
| 108 | + | let success_url = format!("{}/dashboard?sync_subscribed=true", state.config.host_url); | |
| 109 | + | let cancel_url = format!("{}/dashboard", state.config.host_url); | |
| 110 | + | ||
| 111 | + | let session = stripe.create_app_sync_checkout_session(&AppSyncCheckoutParams { | |
| 112 | + | product_name: &product_name, | |
| 113 | + | price_cents, | |
| 114 | + | interval: match interval { | |
| 115 | + | BillingInterval::Monthly => "month", | |
| 116 | + | BillingInterval::Annual => "year", | |
| 117 | + | }, | |
| 118 | + | user_id: sync_user.user_id, | |
| 119 | + | app_id: sync_user.app_id, | |
| 120 | + | app_name: &app.name, | |
| 121 | + | tier: &tier.to_string(), | |
| 122 | + | success_url: &success_url, | |
| 123 | + | cancel_url: &cancel_url, | |
| 124 | + | }).await?; | |
| 125 | + | ||
| 126 | + | let checkout_url = session.url | |
| 127 | + | .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; | |
| 128 | + | ||
| 129 | + | Ok(Json(SubscriptionCheckoutResponse { checkout_url })) | |
| 130 | + | } |
| @@ -23,6 +23,34 @@ use super::{ | |||
| 23 | 23 | RotationEntry, SyncDeviceResponse, SyncStatusResponse, | |
| 24 | 24 | }; | |
| 25 | 25 | ||
| 26 | + | // ── Subscription gate ── | |
| 27 | + | ||
| 28 | + | /// Check if the user has a required sync subscription. | |
| 29 | + | /// | |
| 30 | + | /// GO and BB require an active subscription for metadata sync. | |
| 31 | + | /// AF metadata sync is free (only blob endpoints are gated, server-side). | |
| 32 | + | /// Returns Ok(()) if sync is allowed. | |
| 33 | + | async fn check_sync_subscription(state: &AppState, user_id: db::UserId, app_id: db::SyncAppId) -> Result<()> { | |
| 34 | + | let app = db::synckit::get_sync_app_by_id(&state.db, app_id) | |
| 35 | + | .await? | |
| 36 | + | .ok_or(AppError::Unauthorized)?; | |
| 37 | + | ||
| 38 | + | // AF metadata sync is free — only blob endpoints are gated | |
| 39 | + | let app_name_lower = app.name.to_lowercase(); | |
| 40 | + | if app_name_lower == "audiofiles" { | |
| 41 | + | return Ok(()); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // GO and BB require subscription for all sync | |
| 45 | + | if db::app_sync::has_active_app_sync_sub(&state.db, user_id, app_id).await? { | |
| 46 | + | return Ok(()); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | Err(AppError::PaymentRequired( | |
| 50 | + | "Cloud sync requires an active subscription. Subscribe in your app settings.".to_string(), | |
| 51 | + | )) | |
| 52 | + | } | |
| 53 | + | ||
| 26 | 54 | // ── Sync endpoints (JWT auth) ── | |
| 27 | 55 | ||
| 28 | 56 | /// Push encrypted changelog entries from a device. | |
| @@ -42,6 +70,9 @@ pub(super) async fn sync_push( | |||
| 42 | 70 | tracing::Span::current().record("app_id", tracing::field::display(&app_id)); | |
| 43 | 71 | tracing::Span::current().record("user_id", tracing::field::display(&user_id)); | |
| 44 | 72 | ||
| 73 | + | // Check subscription gate (GO/BB require active subscription) | |
| 74 | + | check_sync_subscription(&state, user_id, app_id).await?; | |
| 75 | + | ||
| 45 | 76 | if req.changes.is_empty() { | |
| 46 | 77 | return Err(AppError::BadRequest("No changes provided".to_string())); | |
| 47 | 78 | } | |
| @@ -122,6 +153,9 @@ pub(super) async fn sync_pull( | |||
| 122 | 153 | tracing::Span::current().record("app_id", tracing::field::display(&app_id)); | |
| 123 | 154 | tracing::Span::current().record("user_id", tracing::field::display(&user_id)); | |
| 124 | 155 | ||
| 156 | + | // Check subscription gate (GO/BB require active subscription) | |
| 157 | + | check_sync_subscription(&state, user_id, app_id).await?; | |
| 158 | + | ||
| 125 | 159 | // Verify device belongs to this user + app | |
| 126 | 160 | let devices = db::synckit::get_sync_devices(&state.db, app_id, user_id).await?; | |
| 127 | 161 | if !devices.iter().any(|d| d.id == req.device_id) { |
| @@ -123,6 +123,10 @@ impl PaymentProvider for MockPaymentProvider { | |||
| 123 | 123 | Ok(self.next_session()) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | async fn create_app_sync_checkout_session(&self, _params: &makenotwork::payments::AppSyncCheckoutParams<'_>) -> Result<CheckoutResult> { | |
| 127 | + | Ok(self.next_session()) | |
| 128 | + | } | |
| 129 | + | ||
| 126 | 130 | async fn create_cart_checkout_session(&self, _params: &makenotwork::payments::CartCheckoutParams<'_>) -> Result<CheckoutResult> { | |
| 127 | 131 | Ok(self.next_session()) | |
| 128 | 132 | } |
| @@ -63,9 +63,12 @@ v0.3.1. Audit grade A. 340 tests (249 unit + integration). Rust 2024 edition (20 | |||
| 63 | 63 | ### Pricing & Revenue | |
| 64 | 64 | - [x] Reconcile pricing models — consolidated into Simple/Builder model in `server/docs/synckit_pricing.md`. Old Shared Pool/Per-User tiers archived in synckit-plan.md. Weight/Burst is the billing engine; Simple mode = Builder with fixed 5x burst ratio. | |
| 65 | 65 | - [x] Re-evaluate free tier — replaced with application-based access + 14-day unbilled trial. Aligns with MNW no-free-tier philosophy. | |
| 66 | - | - [ ] Charge for sync in GO, BB, AF — pricing decided, implementation pending. BB: $1/mo or $8/yr. GO: $2/mo or $15/yr. AF: tiered blob sync ($1-8/mo, $10-80/yr), metadata free. See `server/docs/internal/business/app_sync_pricing.md`. | |
| 66 | + | - [x] Charge for sync in GO, BB, AF — server: migration 098 `app_sync_subscriptions`, sync gate (402 for GO/BB metadata, 402 for AF blobs), inline Stripe checkout (no pre-created products). Client SDK: `get_subscription_status()`, `create_subscription_checkout()`. GO/BB: Tauri commands + settings UI. AF: SyncManager methods + egui subscription panel with tier selector + storage bar. | |
| 67 | 67 | - [x] Clarify SyncKit relationship to MNW creator tiers — no relationship. SyncKit is a separate product for developers. App sync pricing is independent of creator tiers. MNW may offer creators a separate cloud storage budget in the future, but that's a MNW feature, not SyncKit. | |
| 68 | 68 | - [ ] Implement developer application flow — short form (app description, expected usage), manual approval, 14-day unbilled trial starts on approval. | |
| 69 | + | - [ ] Cancellation grace period — decide whether `past_due` subscribers can still sync (probably yes, for a few days). After cancellation, sync stops at billing period end, data retained 30 days before cleanup. | |
| 70 | + | - [ ] Subscription management UI on makenot.work dashboard — show active app sync subscriptions with cancel/change-tier options. | |
| 71 | + | - [ ] Test end-to-end against live Stripe — subscribe via each app, verify webhook, verify gate. | |
| 69 | 72 | ||
| 70 | 73 | ### Operational Prerequisites (before first external customer) | |
| 71 | 74 | - [x] Automate sync_log compaction — already wired in server monitor.rs maintenance loop |
| @@ -55,6 +55,7 @@ mod encryption; | |||
| 55 | 55 | pub(crate) mod helpers; | |
| 56 | 56 | mod rotation; | |
| 57 | 57 | mod subscribe; | |
| 58 | + | pub mod subscription; | |
| 58 | 59 | mod sync; | |
| 59 | 60 | ||
| 60 | 61 | pub use subscribe::SyncNotifyStream; | |
| @@ -102,6 +103,8 @@ struct Endpoints { | |||
| 102 | 103 | blobs_upload: String, | |
| 103 | 104 | blobs_confirm: String, | |
| 104 | 105 | blobs_download: String, | |
| 106 | + | subscription: String, | |
| 107 | + | subscription_checkout: String, | |
| 105 | 108 | } | |
| 106 | 109 | ||
| 107 | 110 | impl Endpoints { | |
| @@ -119,6 +122,8 @@ impl Endpoints { | |||
| 119 | 122 | blobs_upload: format!("{base}/api/v1/sync/blobs/upload"), | |
| 120 | 123 | blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"), | |
| 121 | 124 | blobs_download: format!("{base}/api/v1/sync/blobs/download"), | |
| 125 | + | subscription: format!("{base}/api/v1/sync/subscription"), | |
| 126 | + | subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), | |
| 122 | 127 | } | |
| 123 | 128 | } | |
| 124 | 129 | } |