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
@@ -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 }