max / makenotwork
21 files changed,
+1153 insertions,
-89 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.8.1" | |
| 3 | + | version = "0.8.7" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,33 @@ | |||
| 1 | + | -- End-user SyncKit subscriptions, reintroduced for first-party apps. | |
| 2 | + | -- | |
| 3 | + | -- Migration 098 created `app_sync_subscriptions` and migration 117 dropped it | |
| 4 | + | -- in favor of a developer-pays-MNW billing model. Per-app end-user billing | |
| 5 | + | -- was supposed to live "outside SyncKit" for first-party apps (GO/BB/AF), | |
| 6 | + | -- but we ended up reusing the SyncKit auth/server plumbing for it. This | |
| 7 | + | -- migration restores the table with the original shape plus a new | |
| 8 | + | -- `pending_storage_limit_bytes` column for cap changes that take effect at | |
| 9 | + | -- the next billing cycle (no mid-cycle proration surprises). | |
| 10 | + | -- | |
| 11 | + | -- The pricing model is now formula-driven (see `payments::synckit_app_pricing`) | |
| 12 | + | -- — no tier table, the user picks any cap and the server quotes a price. | |
| 13 | + | CREATE TABLE app_sync_subscriptions ( | |
| 14 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 15 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 16 | + | app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE, | |
| 17 | + | stripe_subscription_id TEXT NOT NULL UNIQUE, | |
| 18 | + | stripe_customer_id TEXT NOT NULL, | |
| 19 | + | -- Billing interval ("monthly" or "annual"). Reuses the legacy `tier` | |
| 20 | + | -- column name so existing SDK code that reads `sub.tier` keeps working. | |
| 21 | + | tier TEXT NOT NULL DEFAULT 'monthly', | |
| 22 | + | status TEXT NOT NULL DEFAULT 'active', | |
| 23 | + | storage_limit_bytes BIGINT, | |
| 24 | + | -- Queued cap change applied at the next billing cycle. | |
| 25 | + | pending_storage_limit_bytes BIGINT, | |
| 26 | + | current_period_start TIMESTAMPTZ, | |
| 27 | + | current_period_end TIMESTAMPTZ, | |
| 28 | + | canceled_at TIMESTAMPTZ, | |
| 29 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 30 | + | ); | |
| 31 | + | ||
| 32 | + | CREATE UNIQUE INDEX idx_app_sync_subs_user_app ON app_sync_subscriptions(user_id, app_id); | |
| 33 | + | CREATE INDEX idx_app_sync_subs_stripe ON app_sync_subscriptions(stripe_subscription_id); |
| @@ -956,6 +956,7 @@ pub enum CheckoutType { | |||
| 956 | 956 | FanPlus, | |
| 957 | 957 | CreatorTier, | |
| 958 | 958 | Cart, | |
| 959 | + | SynckitAppSub, | |
| 959 | 960 | } | |
| 960 | 961 | ||
| 961 | 962 | impl_str_enum!(CheckoutType { | |
| @@ -965,6 +966,7 @@ impl_str_enum!(CheckoutType { | |||
| 965 | 966 | FanPlus => "fan_plus", | |
| 966 | 967 | CreatorTier => "creator_tier", | |
| 967 | 968 | Cart => "cart", | |
| 969 | + | SynckitAppSub => "synckit_app_sub", | |
| 968 | 970 | }); | |
| 969 | 971 | ||
| 970 | 972 | impl ModerationActionType { |
| @@ -10,6 +10,7 @@ mod devices; | |||
| 10 | 10 | mod keys; | |
| 11 | 11 | mod log; | |
| 12 | 12 | mod rotation; | |
| 13 | + | mod subscriptions; | |
| 13 | 14 | ||
| 14 | 15 | pub use apps::*; | |
| 15 | 16 | pub use blobs::*; | |
| @@ -17,3 +18,4 @@ pub use devices::*; | |||
| 17 | 18 | pub use keys::*; | |
| 18 | 19 | pub use log::*; | |
| 19 | 20 | pub use rotation::*; | |
| 21 | + | pub use subscriptions::*; |
| @@ -0,0 +1,178 @@ | |||
| 1 | + | use chrono::{DateTime, Utc}; | |
| 2 | + | use sqlx::PgPool; | |
| 3 | + | ||
| 4 | + | use crate::db::{SyncAppId, UserId}; | |
| 5 | + | use crate::error::Result; | |
| 6 | + | ||
| 7 | + | /// Parameters for inserting a new app sync subscription from a webhook event. | |
| 8 | + | pub struct NewAppSyncSubscription<'a> { | |
| 9 | + | pub user_id: UserId, | |
| 10 | + | pub app_id: SyncAppId, | |
| 11 | + | pub stripe_subscription_id: &'a str, | |
| 12 | + | pub stripe_customer_id: &'a str, | |
| 13 | + | /// Billing interval ("monthly" / "annual"). Persisted in the `tier` column | |
| 14 | + | /// (kept lowercase for that legacy name) so cap-change handlers know which | |
| 15 | + | /// interval the user is on without round-tripping to Stripe. | |
| 16 | + | pub interval: &'a str, | |
| 17 | + | pub storage_limit_bytes: i64, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// End-user subscription to an app's cloud sync (rows in `app_sync_subscriptions`). | |
| 21 | + | #[derive(Debug, sqlx::FromRow)] | |
| 22 | + | pub struct DbAppSyncSubscription { | |
| 23 | + | pub stripe_subscription_id: String, | |
| 24 | + | pub interval: String, | |
| 25 | + | pub status: String, | |
| 26 | + | pub storage_limit_bytes: Option<i64>, | |
| 27 | + | pub pending_storage_limit_bytes: Option<i64>, | |
| 28 | + | pub current_period_end: Option<DateTime<Utc>>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Look up the active subscription a user has on an app, if any. | |
| 32 | + | /// Returns `None` if the user has never subscribed or the subscription was deleted. | |
| 33 | + | #[tracing::instrument(skip_all)] | |
| 34 | + | pub async fn get_user_app_subscription( | |
| 35 | + | pool: &PgPool, | |
| 36 | + | user_id: UserId, | |
| 37 | + | app_id: SyncAppId, | |
| 38 | + | ) -> Result<Option<DbAppSyncSubscription>> { | |
| 39 | + | let row = sqlx::query_as::<_, DbAppSyncSubscription>( | |
| 40 | + | r#" | |
| 41 | + | SELECT stripe_subscription_id, | |
| 42 | + | tier AS interval, | |
| 43 | + | status, | |
| 44 | + | storage_limit_bytes, | |
| 45 | + | pending_storage_limit_bytes, | |
| 46 | + | current_period_end | |
| 47 | + | FROM app_sync_subscriptions | |
| 48 | + | WHERE user_id = $1 AND app_id = $2 | |
| 49 | + | "#, | |
| 50 | + | ) | |
| 51 | + | .bind(user_id) | |
| 52 | + | .bind(app_id) | |
| 53 | + | .fetch_optional(pool) | |
| 54 | + | .await?; | |
| 55 | + | Ok(row) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Insert a new app sync subscription. Returns `Ok(true)` if a row was inserted, | |
| 59 | + | /// `Ok(false)` if a subscription already existed for this (user, app) pair | |
| 60 | + | /// (idempotent webhook replay). | |
| 61 | + | #[tracing::instrument(skip_all)] | |
| 62 | + | pub async fn create_app_sync_subscription( | |
| 63 | + | pool: &PgPool, | |
| 64 | + | sub: &NewAppSyncSubscription<'_>, | |
| 65 | + | ) -> Result<bool> { | |
| 66 | + | let result = sqlx::query( | |
| 67 | + | r#" | |
| 68 | + | INSERT INTO app_sync_subscriptions | |
| 69 | + | (user_id, app_id, stripe_subscription_id, stripe_customer_id, | |
| 70 | + | tier, status, storage_limit_bytes) | |
| 71 | + | VALUES ($1, $2, $3, $4, $5, 'active', $6) | |
| 72 | + | ON CONFLICT (user_id, app_id) DO NOTHING | |
| 73 | + | "#, | |
| 74 | + | ) | |
| 75 | + | .bind(sub.user_id) | |
| 76 | + | .bind(sub.app_id) | |
| 77 | + | .bind(sub.stripe_subscription_id) | |
| 78 | + | .bind(sub.stripe_customer_id) | |
| 79 | + | .bind(sub.interval) | |
| 80 | + | .bind(sub.storage_limit_bytes) | |
| 81 | + | .execute(pool) | |
| 82 | + | .await?; | |
| 83 | + | Ok(result.rows_affected() > 0) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Update the status of an existing app sync subscription (e.g. on | |
| 87 | + | /// `customer.subscription.updated` or `.deleted` webhook events). | |
| 88 | + | #[tracing::instrument(skip_all)] | |
| 89 | + | pub async fn update_app_sync_subscription_status( | |
| 90 | + | pool: &PgPool, | |
| 91 | + | stripe_subscription_id: &str, | |
| 92 | + | status: &str, | |
| 93 | + | current_period_end: Option<DateTime<Utc>>, | |
| 94 | + | ) -> Result<()> { | |
| 95 | + | sqlx::query( | |
| 96 | + | r#" | |
| 97 | + | UPDATE app_sync_subscriptions | |
| 98 | + | SET status = $2, | |
| 99 | + | current_period_end = COALESCE($3, current_period_end), | |
| 100 | + | canceled_at = CASE WHEN $2 = 'canceled' THEN NOW() ELSE canceled_at END | |
| 101 | + | WHERE stripe_subscription_id = $1 | |
| 102 | + | "#, | |
| 103 | + | ) | |
| 104 | + | .bind(stripe_subscription_id) | |
| 105 | + | .bind(status) | |
| 106 | + | .bind(current_period_end) | |
| 107 | + | .execute(pool) | |
| 108 | + | .await?; | |
| 109 | + | Ok(()) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Look up an app sync subscription by its Stripe subscription ID. Used by | |
| 113 | + | /// webhook handlers to find the row to update. | |
| 114 | + | #[tracing::instrument(skip_all)] | |
| 115 | + | pub async fn get_subscription_by_stripe_id( | |
| 116 | + | pool: &PgPool, | |
| 117 | + | stripe_subscription_id: &str, | |
| 118 | + | ) -> Result<Option<(UserId, SyncAppId)>> { | |
| 119 | + | let row: Option<(UserId, SyncAppId)> = sqlx::query_as( | |
| 120 | + | r#" | |
| 121 | + | SELECT user_id, app_id | |
| 122 | + | FROM app_sync_subscriptions | |
| 123 | + | WHERE stripe_subscription_id = $1 | |
| 124 | + | "#, | |
| 125 | + | ) | |
| 126 | + | .bind(stripe_subscription_id) | |
| 127 | + | .fetch_optional(pool) | |
| 128 | + | .await?; | |
| 129 | + | Ok(row) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Queue a storage-cap change to apply at the next billing cycle. Stores the | |
| 133 | + | /// new cap in `pending_storage_limit_bytes`; the renewal webhook handler | |
| 134 | + | /// promotes it to `storage_limit_bytes` once Stripe confirms the period roll. | |
| 135 | + | #[tracing::instrument(skip_all)] | |
| 136 | + | pub async fn set_pending_storage_cap( | |
| 137 | + | pool: &PgPool, | |
| 138 | + | user_id: UserId, | |
| 139 | + | app_id: SyncAppId, | |
| 140 | + | pending_bytes: i64, | |
| 141 | + | ) -> Result<()> { | |
| 142 | + | sqlx::query( | |
| 143 | + | r#" | |
| 144 | + | UPDATE app_sync_subscriptions | |
| 145 | + | SET pending_storage_limit_bytes = $3 | |
| 146 | + | WHERE user_id = $1 AND app_id = $2 | |
| 147 | + | "#, | |
| 148 | + | ) | |
| 149 | + | .bind(user_id) | |
| 150 | + | .bind(app_id) | |
| 151 | + | .bind(pending_bytes) | |
| 152 | + | .execute(pool) | |
| 153 | + | .await?; | |
| 154 | + | Ok(()) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Promote a queued cap change to the active cap. Called from the renewal | |
| 158 | + | /// webhook handler when Stripe rolls the subscription to a new period. | |
| 159 | + | /// No-op if no pending change is queued. | |
| 160 | + | #[tracing::instrument(skip_all)] | |
| 161 | + | pub async fn apply_pending_storage_cap( | |
| 162 | + | pool: &PgPool, | |
| 163 | + | stripe_subscription_id: &str, | |
| 164 | + | ) -> Result<()> { | |
| 165 | + | sqlx::query( | |
| 166 | + | r#" | |
| 167 | + | UPDATE app_sync_subscriptions | |
| 168 | + | SET storage_limit_bytes = pending_storage_limit_bytes, | |
| 169 | + | pending_storage_limit_bytes = NULL | |
| 170 | + | WHERE stripe_subscription_id = $1 | |
| 171 | + | AND pending_storage_limit_bytes IS NOT NULL | |
| 172 | + | "#, | |
| 173 | + | ) | |
| 174 | + | .bind(stripe_subscription_id) | |
| 175 | + | .execute(pool) | |
| 176 | + | .await?; | |
| 177 | + | Ok(()) | |
| 178 | + | } |
| @@ -8,14 +8,15 @@ use std::collections::HashMap; | |||
| 8 | 8 | use stripe::StripeRequest; | |
| 9 | 9 | use stripe_checkout::checkout_session::{ | |
| 10 | 10 | CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, | |
| 11 | - | CreateCheckoutSessionLineItemsPriceData, | |
| 11 | + | CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring, | |
| 12 | + | CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 12 | 13 | CreateCheckoutSessionSubscriptionData, ProductData, | |
| 13 | 14 | }; | |
| 14 | 15 | use stripe_shared::CheckoutSessionMode; | |
| 15 | 16 | use stripe_types::Currency; | |
| 16 | 17 | ||
| 17 | 18 | use crate::constants; | |
| 18 | - | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; | |
| 19 | + | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; | |
| 19 | 20 | use crate::error::{AppError, Result}; | |
| 20 | 21 | use super::StripeClient; | |
| 21 | 22 | ||
| @@ -122,6 +123,27 @@ fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems { | |||
| 122 | 123 | } | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | /// Build an inline recurring line item — used by SyncKit app subscriptions | |
| 127 | + | /// so we don't have to pre-provision Stripe Products and Prices for every | |
| 128 | + | /// (app, tier, interval) combination. | |
| 129 | + | fn build_inline_recurring_line_item( | |
| 130 | + | product_name: &str, | |
| 131 | + | amount_cents: i64, | |
| 132 | + | interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 133 | + | ) -> CreateCheckoutSessionLineItems { | |
| 134 | + | CreateCheckoutSessionLineItems { | |
| 135 | + | price_data: Some(CreateCheckoutSessionLineItemsPriceData { | |
| 136 | + | currency: Currency::USD, | |
| 137 | + | product_data: Some(ProductData::new(product_name.to_string())), | |
| 138 | + | unit_amount: Some(amount_cents), | |
| 139 | + | recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(interval)), | |
| 140 | + | ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD) | |
| 141 | + | }), | |
| 142 | + | quantity: Some(1), | |
| 143 | + | ..CreateCheckoutSessionLineItems::new() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 125 | 147 | fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> { | |
| 126 | 148 | if enable { | |
| 127 | 149 | Some(CreateCheckoutSessionAutomaticTax::new(true)) | |
| @@ -371,4 +393,41 @@ impl StripeClient { | |||
| 371 | 393 | self.send_on_platform(builder, "creator_tier_checkout").await | |
| 372 | 394 | } | |
| 373 | 395 | ||
| 396 | + | /// Build a Checkout Session for an end-user subscribing to an app's cloud | |
| 397 | + | /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline | |
| 398 | + | /// `price_data` so no Stripe Products/Prices need to be pre-configured — | |
| 399 | + | /// the tier name and cents come from the `sync_app_tiers` row. | |
| 400 | + | #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")] | |
| 401 | + | pub async fn create_synckit_app_sub_checkout_session( | |
| 402 | + | &self, | |
| 403 | + | product_name: &str, | |
| 404 | + | amount_cents: i64, | |
| 405 | + | interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 406 | + | user_id: UserId, | |
| 407 | + | app_id: SyncAppId, | |
| 408 | + | tier: &str, | |
| 409 | + | storage_limit_bytes: Option<i64>, | |
| 410 | + | success_url: &str, | |
| 411 | + | cancel_url: &str, | |
| 412 | + | ) -> Result<stripe_shared::CheckoutSession> { | |
| 413 | + | let mut metadata = HashMap::new(); | |
| 414 | + | metadata.insert("checkout_type".to_string(), CheckoutType::SynckitAppSub.to_string()); | |
| 415 | + | metadata.insert("user_id".to_string(), user_id.to_string()); | |
| 416 | + | metadata.insert("app_id".to_string(), app_id.to_string()); | |
| 417 | + | metadata.insert("tier".to_string(), tier.to_string()); | |
| 418 | + | if let Some(bytes) = storage_limit_bytes { | |
| 419 | + | metadata.insert("storage_limit_bytes".to_string(), bytes.to_string()); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | let line_item = build_inline_recurring_line_item(product_name, amount_cents, interval); | |
| 423 | + | ||
| 424 | + | let builder = CreateCheckoutSession::new() | |
| 425 | + | .mode(CheckoutSessionMode::Subscription) | |
| 426 | + | .success_url(success_url.to_string()) | |
| 427 | + | .cancel_url(cancel_url.to_string()) | |
| 428 | + | .line_items(vec![line_item]) | |
| 429 | + | .metadata(metadata); | |
| 430 | + | ||
| 431 | + | self.send_on_platform(builder, "synckit_app_sub_checkout").await | |
| 432 | + | } | |
| 374 | 433 | } |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | 8 | use std::collections::HashMap; | |
| 9 | 9 | ||
| 10 | - | use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; | |
| 10 | + | use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; | |
| 11 | 11 | use crate::error::{AppError, Result}; | |
| 12 | 12 | ||
| 13 | 13 | /// Convenience alias for the metadata pulled off a Stripe `CheckoutSession`. | |
| @@ -176,6 +176,31 @@ pub fn is_cart_checkout(meta: Option<&CheckoutMetaMap>) -> bool { | |||
| 176 | 176 | get_checkout_type(meta) == Some(CheckoutType::Cart) | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | pub fn is_synckit_app_sub_checkout(meta: Option<&CheckoutMetaMap>) -> bool { | |
| 180 | + | get_checkout_type(meta) == Some(CheckoutType::SynckitAppSub) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// Parsed metadata for an end-user subscribing to an app's cloud sync. | |
| 184 | + | #[derive(Debug)] | |
| 185 | + | pub struct SynckitAppSubCheckoutMetadata { | |
| 186 | + | pub user_id: UserId, | |
| 187 | + | pub app_id: SyncAppId, | |
| 188 | + | pub tier: String, | |
| 189 | + | pub storage_limit_bytes: Option<i64>, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | impl SynckitAppSubCheckoutMetadata { | |
| 193 | + | pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> { | |
| 194 | + | let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; | |
| 195 | + | let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?; | |
| 196 | + | let tier = require(meta, "tier")?.clone(); | |
| 197 | + | let storage_limit_bytes = meta | |
| 198 | + | .and_then(|m| m.get("storage_limit_bytes")) | |
| 199 | + | .and_then(|v| v.parse::<i64>().ok()); | |
| 200 | + | Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, tier, storage_limit_bytes }) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 179 | 204 | #[cfg(test)] | |
| 180 | 205 | mod tests { | |
| 181 | 206 | use super::*; |
| @@ -17,11 +17,13 @@ | |||
| 17 | 17 | mod checkout; | |
| 18 | 18 | mod checkout_metadata; | |
| 19 | 19 | mod connect; | |
| 20 | + | pub mod synckit_app_pricing; | |
| 20 | 21 | pub mod synckit_billing; | |
| 21 | 22 | mod webhooks; | |
| 22 | 23 | ||
| 23 | 24 | pub use checkout::*; | |
| 24 | 25 | pub use checkout_metadata::*; | |
| 26 | + | pub use synckit_app_pricing::{quote_price_cents, SyncBillingInterval, ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS}; | |
| 25 | 27 | pub use synckit_billing::SynckitSubResult; | |
| 26 | 28 | pub use webhooks::*; | |
| 27 | 29 | ||
| @@ -77,6 +79,7 @@ pub trait PaymentProvider: Send + Sync { | |||
| 77 | 79 | async fn create_tip_checkout_session(&self, params: &TipCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 78 | 80 | 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>; | |
| 79 | 81 | 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>; | |
| 82 | + | async fn create_synckit_app_sub_checkout_session(&self, product_name: &str, amount_cents: i64, interval: &str, user_id: crate::db::UserId, app_id: crate::db::SyncAppId, tier: &str, storage_limit_bytes: Option<i64>, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 80 | 83 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 81 | 84 | ||
| 82 | 85 | // Connect | |
| @@ -112,6 +115,9 @@ pub trait PaymentProvider: Send + Sync { | |||
| 112 | 115 | async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, email: &str, app_name: &str) -> crate::error::Result<String>; | |
| 113 | 116 | async fn create_synckit_subscription(&self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64) -> crate::error::Result<SynckitSubResult>; | |
| 114 | 117 | async fn update_synckit_subscription_price(&self, subscription_id: &str, new_price_cents: i64, app_name: &str) -> crate::error::Result<()>; | |
| 118 | + | /// Re-price an end-user SyncKit app subscription. Used by the cap-change | |
| 119 | + | /// path; takes effect at next billing cycle (no proration). | |
| 120 | + | async fn update_synckit_app_sub_price(&self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str) -> crate::error::Result<()>; | |
| 115 | 121 | async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>; | |
| 116 | 122 | async fn create_synckit_billing_portal(&self, customer_id: &str, return_url: &str) -> crate::error::Result<String>; | |
| 117 | 123 | } | |
| @@ -148,6 +154,17 @@ impl PaymentProvider for StripeClient { | |||
| 148 | 154 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 149 | 155 | } | |
| 150 | 156 | ||
| 157 | + | async fn create_synckit_app_sub_checkout_session(&self, product_name: &str, amount_cents: i64, interval: &str, user_id: crate::db::UserId, app_id: crate::db::SyncAppId, tier: &str, storage_limit_bytes: Option<i64>, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult> { | |
| 158 | + | use stripe_checkout::checkout_session::CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring; | |
| 159 | + | let interval = match interval { | |
| 160 | + | "monthly" => Recurring::Month, | |
| 161 | + | "annual" => Recurring::Year, | |
| 162 | + | other => return Err(crate::error::AppError::BadRequest(format!("Invalid interval '{other}'"))), | |
| 163 | + | }; | |
| 164 | + | let session = StripeClient::create_synckit_app_sub_checkout_session(self, product_name, amount_cents, interval, user_id, app_id, tier, storage_limit_bytes, success_url, cancel_url).await?; | |
| 165 | + | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 166 | + | } | |
| 167 | + | ||
| 151 | 168 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult> { | |
| 152 | 169 | let session = StripeClient::create_cart_checkout_session(self, params).await?; | |
| 153 | 170 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| @@ -238,6 +255,10 @@ impl PaymentProvider for StripeClient { | |||
| 238 | 255 | StripeClient::update_synckit_subscription_price(self, subscription_id, new_price_cents, app_name).await | |
| 239 | 256 | } | |
| 240 | 257 | ||
| 258 | + | async fn update_synckit_app_sub_price(&self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str) -> crate::error::Result<()> { | |
| 259 | + | StripeClient::update_synckit_app_sub_price(self, subscription_id, new_price_cents, interval, product_name).await | |
| 260 | + | } | |
| 261 | + | ||
| 241 | 262 | async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> { | |
| 242 | 263 | StripeClient::cancel_synckit_subscription(self, subscription_id).await | |
| 243 | 264 | } |
| @@ -0,0 +1,145 @@ | |||
| 1 | + | //! Formula-driven pricing for end-user SyncKit subscriptions. | |
| 2 | + | //! | |
| 3 | + | //! No tier table — users pick any storage cap and the server quotes a price | |
| 4 | + | //! computed from a fixed formula. Constants are kept in code (not the DB) | |
| 5 | + | //! because there is exactly one formula in production today and a schema row | |
| 6 | + | //! would just be indirection. | |
| 7 | + | ||
| 8 | + | use crate::error::{AppError, Result}; | |
| 9 | + | ||
| 10 | + | /// Per-GB monthly cost in tenths of a cent. 8 ⇒ $0.008/GB/month | |
| 11 | + | /// (0.8¢ = 8 tenths-of-a-cent), chosen to roughly cover Hetzner Object | |
| 12 | + | /// Storage at the rack rate plus a thin egress buffer. | |
| 13 | + | pub const PER_GB_TENTHS_OF_CENT_PER_MONTH: i64 = 8; | |
| 14 | + | ||
| 15 | + | /// Minimum monthly or annual charge in cents. Covers Stripe's per-transaction | |
| 16 | + | /// floor ($0.30 + 2.9%) without forcing the price up further for tiny accounts. | |
| 17 | + | pub const MIN_CHARGE_CENTS: i64 = 200; | |
| 18 | + | ||
| 19 | + | /// Annual price multiplier vs. monthly. ×10 = "two months free" structurally, | |
| 20 | + | /// which is also where Stripe per-transaction fees disappear into the noise. | |
| 21 | + | pub const ANNUAL_MULTIPLIER: i64 = 10; | |
| 22 | + | ||
| 23 | + | /// Minimum and maximum storage caps the user may pick. | |
| 24 | + | pub const MIN_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; // 10 GiB | |
| 25 | + | pub const MAX_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024 * 1024; // 10 TiB | |
| 26 | + | ||
| 27 | + | /// Billing interval for a SyncKit app subscription. | |
| 28 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 29 | + | pub enum SyncBillingInterval { | |
| 30 | + | Monthly, | |
| 31 | + | Annual, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl SyncBillingInterval { | |
| 35 | + | pub fn parse(s: &str) -> Result<Self> { | |
| 36 | + | match s { | |
| 37 | + | "monthly" => Ok(Self::Monthly), | |
| 38 | + | "annual" => Ok(Self::Annual), | |
| 39 | + | other => Err(AppError::BadRequest(format!( | |
| 40 | + | "Invalid interval '{other}', expected 'monthly' or 'annual'" | |
| 41 | + | ))), | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | pub fn as_str(self) -> &'static str { | |
| 46 | + | match self { | |
| 47 | + | Self::Monthly => "monthly", | |
| 48 | + | Self::Annual => "annual", | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn cap_bytes_to_gb_ceil(cap_bytes: i64) -> i64 { | |
| 54 | + | const GB: i64 = 1024 * 1024 * 1024; | |
| 55 | + | (cap_bytes + GB - 1) / GB | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Compute the price (in cents) for a given storage cap and interval. | |
| 59 | + | /// | |
| 60 | + | /// `cap_bytes` is validated against `MIN_CAP_BYTES` / `MAX_CAP_BYTES`. | |
| 61 | + | pub fn quote_price_cents(cap_bytes: i64, interval: SyncBillingInterval) -> Result<i64> { | |
| 62 | + | if cap_bytes < MIN_CAP_BYTES { | |
| 63 | + | return Err(AppError::BadRequest(format!( | |
| 64 | + | "Storage cap must be at least {} GiB", | |
| 65 | + | MIN_CAP_BYTES / (1024 * 1024 * 1024) | |
| 66 | + | ))); | |
| 67 | + | } | |
| 68 | + | if cap_bytes > MAX_CAP_BYTES { | |
| 69 | + | return Err(AppError::BadRequest(format!( | |
| 70 | + | "Storage cap may not exceed {} GiB", | |
| 71 | + | MAX_CAP_BYTES / (1024 * 1024 * 1024) | |
| 72 | + | ))); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | let gb = cap_bytes_to_gb_ceil(cap_bytes); | |
| 76 | + | // Storage-driven monthly cents, ceiling-divided so partial cents round up. | |
| 77 | + | let storage_monthly_cents = gb * PER_GB_TENTHS_OF_CENT_PER_MONTH; | |
| 78 | + | let storage_monthly_cents = (storage_monthly_cents + 9) / 10; | |
| 79 | + | let monthly_cents = storage_monthly_cents.max(MIN_CHARGE_CENTS); | |
| 80 | + | ||
| 81 | + | Ok(match interval { | |
| 82 | + | SyncBillingInterval::Monthly => monthly_cents, | |
| 83 | + | SyncBillingInterval::Annual => monthly_cents * ANNUAL_MULTIPLIER, | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[cfg(test)] | |
| 88 | + | mod tests { | |
| 89 | + | use super::*; | |
| 90 | + | ||
| 91 | + | fn gb(n: i64) -> i64 { | |
| 92 | + | n * 1024 * 1024 * 1024 | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn minimum_cap_hits_floor() { | |
| 97 | + | let price = quote_price_cents(gb(10), SyncBillingInterval::Monthly).unwrap(); | |
| 98 | + | assert_eq!(price, MIN_CHARGE_CENTS); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn small_cap_uses_floor_until_breakeven() { | |
| 103 | + | // 100 GB × $0.008 = $0.80 → below the $2 floor. | |
| 104 | + | let price = quote_price_cents(gb(100), SyncBillingInterval::Monthly).unwrap(); | |
| 105 | + | assert_eq!(price, MIN_CHARGE_CENTS); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn breakeven_around_250gb() { | |
| 110 | + | // 250 GB × $0.008 = $2.00 → equal to floor; storage formula starts taking over. | |
| 111 | + | let price = quote_price_cents(gb(250), SyncBillingInterval::Monthly).unwrap(); | |
| 112 | + | assert_eq!(price, 200); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn large_cap_scales() { | |
| 117 | + | // 1 TiB = 1024 GiB × $0.008 = $8.192 → 820 cents (ceil). | |
| 118 | + | let price = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap(); | |
| 119 | + | assert_eq!(price, 820); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[test] | |
| 123 | + | fn annual_is_ten_times_monthly() { | |
| 124 | + | let monthly = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap(); | |
| 125 | + | let annual = quote_price_cents(gb(1024), SyncBillingInterval::Annual).unwrap(); | |
| 126 | + | assert_eq!(annual, monthly * 10); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn below_minimum_cap_rejected() { | |
| 131 | + | assert!(quote_price_cents(gb(5), SyncBillingInterval::Monthly).is_err()); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[test] | |
| 135 | + | fn above_maximum_cap_rejected() { | |
| 136 | + | assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err()); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn interval_parse_round_trip() { | |
| 141 | + | assert_eq!(SyncBillingInterval::parse("monthly").unwrap(), SyncBillingInterval::Monthly); | |
| 142 | + | assert_eq!(SyncBillingInterval::parse("annual").unwrap(), SyncBillingInterval::Annual); | |
| 143 | + | assert!(SyncBillingInterval::parse("weekly").is_err()); | |
| 144 | + | } | |
| 145 | + | } |
| @@ -17,7 +17,9 @@ use stripe_billing::subscription::{ | |||
| 17 | 17 | CancelSubscription, CreateSubscription, CreateSubscriptionItems, | |
| 18 | 18 | CreateSubscriptionItemsPriceData, CreateSubscriptionItemsPriceDataRecurring, | |
| 19 | 19 | CreateSubscriptionItemsPriceDataRecurringInterval, RetrieveSubscription, UpdateSubscription, | |
| 20 | - | UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData, UpdateSubscriptionProrationBehavior, | |
| 20 | + | UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData, | |
| 21 | + | UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval, | |
| 22 | + | UpdateSubscriptionProrationBehavior, | |
| 21 | 23 | }; | |
| 22 | 24 | use stripe_core::customer::CreateCustomer; | |
| 23 | 25 | use stripe_product::product::CreateProduct; | |
| @@ -221,6 +223,81 @@ impl StripeClient { | |||
| 221 | 223 | Ok(()) | |
| 222 | 224 | } | |
| 223 | 225 | ||
| 226 | + | /// Re-price an end-user SyncKit app subscription (the per-user subs that | |
| 227 | + | /// run on MNW's own Stripe account, distinct from the developer-billing | |
| 228 | + | /// subs above). Used by the storage-cap change path: when a user queues a | |
| 229 | + | /// new cap, we update Stripe to charge the new price *at the next billing | |
| 230 | + | /// cycle* — `proration_behavior=None` — so the cap and the price flip | |
| 231 | + | /// together at the period boundary, matching the DB pending-cap semantics. | |
| 232 | + | #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")] | |
| 233 | + | pub async fn update_synckit_app_sub_price( | |
| 234 | + | &self, | |
| 235 | + | subscription_id: &str, | |
| 236 | + | new_price_cents: i64, | |
| 237 | + | interval: super::SyncBillingInterval, | |
| 238 | + | product_name: &str, | |
| 239 | + | ) -> Result<()> { | |
| 240 | + | if new_price_cents <= 0 { | |
| 241 | + | return Err(AppError::BadRequest( | |
| 242 | + | "Subscription price must be positive".to_string(), | |
| 243 | + | )); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | let sub_id = parse_subscription_id(subscription_id)?; | |
| 247 | + | ||
| 248 | + | let existing = RetrieveSubscription::new(sub_id.clone()) | |
| 249 | + | .send(&self.client) | |
| 250 | + | .await | |
| 251 | + | .map_err(|e| { | |
| 252 | + | tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub"); | |
| 253 | + | AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription")) | |
| 254 | + | })?; | |
| 255 | + | ||
| 256 | + | let existing_item = existing.items.data.first().ok_or_else(|| { | |
| 257 | + | AppError::Internal(anyhow::anyhow!( | |
| 258 | + | "Stripe subscription {} has no items", | |
| 259 | + | subscription_id | |
| 260 | + | )) | |
| 261 | + | })?; | |
| 262 | + | ||
| 263 | + | let product_id = existing_item.price.product.id().to_string(); | |
| 264 | + | let _ = product_name; | |
| 265 | + | ||
| 266 | + | let recurring_interval = match interval { | |
| 267 | + | super::SyncBillingInterval::Monthly => { | |
| 268 | + | UpdateSubscriptionItemsPriceDataRecurringInterval::Month | |
| 269 | + | } | |
| 270 | + | super::SyncBillingInterval::Annual => { | |
| 271 | + | UpdateSubscriptionItemsPriceDataRecurringInterval::Year | |
| 272 | + | } | |
| 273 | + | }; | |
| 274 | + | ||
| 275 | + | let new_price_data = UpdateSubscriptionItemsPriceData { | |
| 276 | + | currency: Currency::USD, | |
| 277 | + | product: product_id, | |
| 278 | + | recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval), | |
| 279 | + | tax_behavior: None, | |
| 280 | + | unit_amount: Some(new_price_cents), | |
| 281 | + | unit_amount_decimal: None, | |
| 282 | + | }; | |
| 283 | + | ||
| 284 | + | let mut item = UpdateSubscriptionItems::default(); | |
| 285 | + | item.id = Some(existing_item.id.to_string()); | |
| 286 | + | item.price_data = Some(new_price_data); | |
| 287 | + | ||
| 288 | + | UpdateSubscription::new(sub_id) | |
| 289 | + | .items(vec![item]) | |
| 290 | + | .proration_behavior(UpdateSubscriptionProrationBehavior::None) | |
| 291 | + | .send(&self.client) | |
| 292 | + | .await | |
| 293 | + | .map_err(|e| { | |
| 294 | + | tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub"); | |
| 295 | + | AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription")) | |
| 296 | + | })?; | |
| 297 | + | ||
| 298 | + | Ok(()) | |
| 299 | + | } | |
| 300 | + | ||
| 224 | 301 | /// Cancel a SyncKit subscription immediately. | |
| 225 | 302 | /// | |
| 226 | 303 | /// We cancel immediately (rather than at_period_end=true) because the |
| @@ -22,6 +22,34 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 22 | 22 | ||
| 23 | 23 | let is_renewal = invoice.is_renewal(); | |
| 24 | 24 | ||
| 25 | + | // End-user SyncKit app subscription? Apply any pending storage-cap change | |
| 26 | + | // and refresh the period. Only meaningful on renewals; the first invoice's | |
| 27 | + | // cap was set at checkout. | |
| 28 | + | if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id) | |
| 29 | + | .await | |
| 30 | + | .context("fetch app sync subscription by stripe id")? | |
| 31 | + | .is_some() | |
| 32 | + | { | |
| 33 | + | let period_end = stripe_timestamp(invoice.period_end); | |
| 34 | + | db::synckit::update_app_sync_subscription_status( | |
| 35 | + | &state.db, &stripe_sub_id, "active", Some(period_end), | |
| 36 | + | ) | |
| 37 | + | .await | |
| 38 | + | .context("refresh app sync subscription period")?; | |
| 39 | + | if is_renewal { | |
| 40 | + | db::synckit::apply_pending_storage_cap(&state.db, &stripe_sub_id) | |
| 41 | + | .await | |
| 42 | + | .context("apply pending storage cap")?; | |
| 43 | + | } | |
| 44 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 45 | + | &state.db, None, event_id, "invoice.payment_succeeded.synckit_app_sub", | |
| 46 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), | |
| 47 | + | ).await { | |
| 48 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 49 | + | } | |
| 50 | + | return Ok(()); | |
| 51 | + | } | |
| 52 | + | ||
| 25 | 53 | // SyncKit v2 developer subscription? Identified by the local sync_apps row. | |
| 26 | 54 | if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { | |
| 27 | 55 | let period_start = stripe_timestamp(invoice.period_start); |
| @@ -4,7 +4,7 @@ use crate::{ | |||
| 4 | 4 | db, | |
| 5 | 5 | error::{AppError, Result, ResultExt}, | |
| 6 | 6 | helpers::{self, spawn_email}, | |
| 7 | - | payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, TipCheckoutMetadata}, | |
| 7 | + | payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, SynckitAppSubCheckoutMetadata, TipCheckoutMetadata}, | |
| 8 | 8 | AppState, | |
| 9 | 9 | }; | |
| 10 | 10 | ||
| @@ -635,3 +635,57 @@ pub(super) async fn handle_guest_checkout_completed( | |||
| 635 | 635 | ||
| 636 | 636 | Ok(()) | |
| 637 | 637 | } | |
| 638 | + | ||
| 639 | + | /// Handle checkout.session.completed for an end-user SyncKit app subscription. | |
| 640 | + | /// Inserts the `app_sync_subscriptions` row; subsequent | |
| 641 | + | /// `customer.subscription.updated/.deleted` events keep it in sync. | |
| 642 | + | #[tracing::instrument(skip_all, name = "stripe::handle_synckit_app_sub_checkout")] | |
| 643 | + | pub(super) async fn handle_synckit_app_sub_checkout_completed( | |
| 644 | + | state: &AppState, | |
| 645 | + | session: &crate::payments::CheckoutSessionView, | |
| 646 | + | event_id: &str, | |
| 647 | + | ) -> Result<()> { | |
| 648 | + | let session_id = session.id.clone(); | |
| 649 | + | tracing::info!(session_id = %session_id, "processing completed SyncKit app subscription checkout"); | |
| 650 | + | ||
| 651 | + | let meta = SynckitAppSubCheckoutMetadata::from_metadata(session.metadata.as_ref())?; | |
| 652 | + | ||
| 653 | + | let stripe_subscription_id = session | |
| 654 | + | .subscription | |
| 655 | + | .clone() | |
| 656 | + | .ok_or_else(|| AppError::BadRequest("Missing subscription ID on session".to_string()))?; | |
| 657 | + | let stripe_customer_id = session | |
| 658 | + | .customer | |
| 659 | + | .clone() | |
| 660 | + | .ok_or_else(|| AppError::BadRequest("Missing customer ID on session".to_string()))?; | |
| 661 | + | ||
| 662 | + | let inserted = db::synckit::create_app_sync_subscription( | |
| 663 | + | &state.db, | |
| 664 | + | &db::synckit::NewAppSyncSubscription { | |
| 665 | + | user_id: meta.user_id, | |
| 666 | + | app_id: meta.app_id, | |
| 667 | + | stripe_subscription_id: &stripe_subscription_id, | |
| 668 | + | stripe_customer_id: &stripe_customer_id, | |
| 669 | + | interval: &meta.tier, // metadata "tier" carries the interval string ("monthly"/"annual") | |
| 670 | + | storage_limit_bytes: meta.storage_limit_bytes.unwrap_or(0), | |
| 671 | + | }, | |
| 672 | + | ) | |
| 673 | + | .await | |
| 674 | + | .with_context(|| { | |
| 675 | + | format!( | |
| 676 | + | "create app sync subscription user={} app={}", | |
| 677 | + | meta.user_id, meta.app_id | |
| 678 | + | ) | |
| 679 | + | })?; | |
| 680 | + | ||
| 681 | + | if !inserted { | |
| 682 | + | tracing::info!( | |
| 683 | + | user_id = %meta.user_id, | |
| 684 | + | app_id = %meta.app_id, | |
| 685 | + | "SyncKit app subscription already exists, ignoring duplicate webhook" | |
| 686 | + | ); | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | let _ = event_id; | |
| 690 | + | Ok(()) | |
| 691 | + | } |
| @@ -106,6 +106,8 @@ pub(crate) async fn process_webhook_event( | |||
| 106 | 106 | checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?; | |
| 107 | 107 | } else if payments::is_creator_tier_checkout(meta) { | |
| 108 | 108 | checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?; | |
| 109 | + | } else if payments::is_synckit_app_sub_checkout(meta) { | |
| 110 | + | checkout::handle_synckit_app_sub_checkout_completed(state, &session, event_id).await?; | |
| 109 | 111 | } else if payments::is_tip_checkout(meta) { | |
| 110 | 112 | checkout::handle_tip_checkout_completed(state, &session, event_id).await?; | |
| 111 | 113 | } else if payments::is_subscription_checkout(meta) { |
| @@ -37,6 +37,31 @@ pub(super) async fn handle_subscription_updated( | |||
| 37 | 37 | return Ok(()); | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | // Check if this is an end-user SyncKit app subscription. | |
| 41 | + | if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id) | |
| 42 | + | .await | |
| 43 | + | .context("fetch app sync subscription by stripe id")? | |
| 44 | + | .is_some() | |
| 45 | + | { | |
| 46 | + | let (_, end_ts) = sub.current_period().unwrap_or((0, 0)); | |
| 47 | + | let period_end = if end_ts > 0 { Some(stripe_timestamp(end_ts)) } else { None }; | |
| 48 | + | db::synckit::update_app_sync_subscription_status( | |
| 49 | + | &state.db, | |
| 50 | + | &stripe_sub_id, | |
| 51 | + | sub.status.as_str(), | |
| 52 | + | period_end, | |
| 53 | + | ) | |
| 54 | + | .await | |
| 55 | + | .context("update app sync subscription status")?; | |
| 56 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 57 | + | &state.db, None, event_id, "customer.subscription.updated.synckit_app_sub", | |
| 58 | + | &serde_json::json!({"status": sub.status}), | |
| 59 | + | ).await { | |
| 60 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 61 | + | } | |
| 62 | + | return Ok(()); | |
| 63 | + | } | |
| 64 | + | ||
| 40 | 65 | // Check if this is a Fan+ subscription | |
| 41 | 66 | if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? { | |
| 42 | 67 | let status_str = sub.status.as_str(); | |
| @@ -138,6 +163,26 @@ pub(super) async fn handle_subscription_deleted( | |||
| 138 | 163 | return Ok(()); | |
| 139 | 164 | } | |
| 140 | 165 | ||
| 166 | + | // Check if this is an end-user SyncKit app subscription. | |
| 167 | + | if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id) | |
| 168 | + | .await | |
| 169 | + | .context("fetch app sync subscription by stripe id")? | |
| 170 | + | .is_some() | |
| 171 | + | { | |
| 172 | + | db::synckit::update_app_sync_subscription_status( | |
| 173 | + | &state.db, &stripe_sub_id, "canceled", None, | |
| 174 | + | ) | |
| 175 | + | .await | |
| 176 | + | .context("cancel app sync subscription")?; | |
| 177 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 178 | + | &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub", | |
| 179 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id}), | |
| 180 | + | ).await { | |
| 181 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 182 | + | } | |
| 183 | + | return Ok(()); | |
| 184 | + | } | |
| 185 | + | ||
| 141 | 186 | // Check if this is a Fan+ subscription | |
| 142 | 187 | if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? { | |
| 143 | 188 | db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?; |
| @@ -184,6 +184,80 @@ pub(crate) struct SyncAccountResponse { | |||
| 184 | 184 | pub username: String, | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | /// Status of the authenticated user's subscription to this app's cloud sync. | |
| 188 | + | /// Shape matches `synckit_client::SubscriptionStatus`. | |
| 189 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 190 | + | pub(crate) struct SyncSubscriptionStatusResponse { | |
| 191 | + | pub active: bool, | |
| 192 | + | /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier` | |
| 193 | + | /// key for client SDK backwards compatibility. | |
| 194 | + | pub tier: Option<String>, | |
| 195 | + | pub status: Option<String>, | |
| 196 | + | pub storage_limit_bytes: Option<i64>, | |
| 197 | + | /// Queued storage cap, applied at the next billing cycle. `None` when no | |
| 198 | + | /// change is pending. | |
| 199 | + | pub pending_storage_limit_bytes: Option<i64>, | |
| 200 | + | pub storage_used_bytes: Option<i64>, | |
| 201 | + | pub current_period_end: Option<String>, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by | |
| 205 | + | /// its public API key; no JWT required so the UI can quote pricing pre-login. | |
| 206 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 207 | + | pub(crate) struct AppPricingRequest { | |
| 208 | + | pub api_key: String, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Pricing formula constants the client uses to quote a price locally as the | |
| 212 | + | /// user drags a cap slider. The same formula is enforced server-side at | |
| 213 | + | /// checkout — clients are not trusted to compute the final price. | |
| 214 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 215 | + | pub(crate) struct AppPricingResponse { | |
| 216 | + | pub app_name: String, | |
| 217 | + | /// Floor charge in cents (monthly or annual — same floor applies to both). | |
| 218 | + | pub min_charge_cents: i64, | |
| 219 | + | /// Per-GiB monthly storage rate, in tenths of a cent. | |
| 220 | + | pub per_gb_tenths_of_cent_per_month: i64, | |
| 221 | + | /// Annual is monthly × this value. | |
| 222 | + | pub annual_multiplier: i64, | |
| 223 | + | pub min_cap_bytes: i64, | |
| 224 | + | pub max_cap_bytes: i64, | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Request body for `POST /api/v1/sync/subscription/quote`. | |
| 228 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 229 | + | pub(crate) struct SyncQuoteRequest { | |
| 230 | + | pub cap_bytes: i64, | |
| 231 | + | pub interval: String, | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 235 | + | pub(crate) struct SyncQuoteResponse { | |
| 236 | + | pub cap_bytes: i64, | |
| 237 | + | pub interval: String, | |
| 238 | + | pub price_cents: i64, | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// Request body for `POST /api/v1/sync/subscription/checkout`. | |
| 242 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 243 | + | pub(crate) struct SyncSubscribeRequest { | |
| 244 | + | pub cap_bytes: i64, | |
| 245 | + | /// "monthly" or "annual". | |
| 246 | + | pub interval: String, | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 250 | + | pub(crate) struct SyncCheckoutResponse { | |
| 251 | + | pub checkout_url: String, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// Request body for `POST /api/v1/sync/subscription/storage-cap` — queues a | |
| 255 | + | /// cap change that applies at the next billing cycle. | |
| 256 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 257 | + | pub(crate) struct SyncCapChangeRequest { | |
| 258 | + | pub cap_bytes: i64, | |
| 259 | + | } | |
| 260 | + | ||
| 187 | 261 | #[derive(Deserialize, utoipa::ToSchema)] | |
| 188 | 262 | pub(crate) struct PutKeyRequest { | |
| 189 | 263 | pub encrypted_key: String, | |
| @@ -466,6 +540,8 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 466 | 540 | .route("/api/v1/sync/keys/release", post(keys::release)) | |
| 467 | 541 | .route("/api/sync/keys/list", post(keys::list)) | |
| 468 | 542 | .route("/api/v1/sync/keys/list", post(keys::list)) | |
| 543 | + | .route("/api/sync/app/pricing", post(sync::get_app_pricing)) | |
| 544 | + | .route("/api/v1/sync/app/pricing", post(sync::get_app_pricing)) | |
| 469 | 545 | .route_layer(GovernorLayer { | |
| 470 | 546 | config: auth_rate_limit, | |
| 471 | 547 | }); | |
| @@ -484,6 +560,14 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 484 | 560 | .route("/api/v1/sync/status", get(sync::sync_status)) | |
| 485 | 561 | .route("/api/sync/account", get(sync::sync_account)) | |
| 486 | 562 | .route("/api/v1/sync/account", get(sync::sync_account)) | |
| 563 | + | .route("/api/sync/subscription", get(sync::sync_subscription_status)) | |
| 564 | + | .route("/api/v1/sync/subscription", get(sync::sync_subscription_status)) | |
| 565 | + | .route("/api/sync/subscription/quote", post(sync::quote_subscription_price)) | |
| 566 | + | .route("/api/v1/sync/subscription/quote", post(sync::quote_subscription_price)) | |
| 567 | + | .route("/api/sync/subscription/checkout", post(sync::create_subscription_checkout)) | |
| 568 | + | .route("/api/v1/sync/subscription/checkout", post(sync::create_subscription_checkout)) | |
| 569 | + | .route("/api/sync/subscription/storage-cap", post(sync::queue_storage_cap_change)) | |
| 570 | + | .route("/api/v1/sync/subscription/storage-cap", post(sync::queue_storage_cap_change)) | |
| 487 | 571 | .route("/api/sync/devices", post(sync::register_device)) | |
| 488 | 572 | .route("/api/v1/sync/devices", post(sync::register_device)) | |
| 489 | 573 | .route("/api/sync/devices", get(sync::list_devices)) |
| @@ -10,17 +10,20 @@ use crate::{ | |||
| 10 | 10 | constants, | |
| 11 | 11 | db::{self, SyncDeviceId}, | |
| 12 | 12 | error::{AppError, Result}, | |
| 13 | + | payments::{self, SyncBillingInterval}, | |
| 13 | 14 | synckit_auth::SyncUser, | |
| 14 | 15 | validation, | |
| 15 | 16 | AppState, | |
| 16 | 17 | }; | |
| 17 | 18 | ||
| 18 | 19 | use super::{ | |
| 19 | - | BeginRotationRequest, BeginRotationResponse, CompleteRotationErrorResponse, | |
| 20 | - | CompleteRotationRequest, GetKeyResponse, PendingKeyInfo, PullChangeEntry, PullRequest, | |
| 21 | - | PullResponse, PushRequest, PushResponse, PutKeyRequest, RegisterDeviceRequest, | |
| 22 | - | RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest, RotationEntriesResponse, | |
| 23 | - | RotationEntry, SyncAccountResponse, SyncDeviceResponse, SyncStatusResponse, | |
| 20 | + | AppPricingRequest, AppPricingResponse, BeginRotationRequest, BeginRotationResponse, | |
| 21 | + | CompleteRotationErrorResponse, CompleteRotationRequest, GetKeyResponse, PendingKeyInfo, | |
| 22 | + | PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, PutKeyRequest, | |
| 23 | + | RegisterDeviceRequest, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest, | |
| 24 | + | RotationEntriesResponse, RotationEntry, SyncAccountResponse, SyncCapChangeRequest, | |
| 25 | + | SyncCheckoutResponse, SyncDeviceResponse, SyncQuoteRequest, SyncQuoteResponse, | |
| 26 | + | SyncStatusResponse, SyncSubscribeRequest, SyncSubscriptionStatusResponse, | |
| 24 | 27 | }; | |
| 25 | 28 | ||
| 26 | 29 | // ── Sync endpoints (JWT auth) ── | |
| @@ -228,6 +231,235 @@ pub(super) async fn sync_account( | |||
| 228 | 231 | })) | |
| 229 | 232 | } | |
| 230 | 233 | ||
| 234 | + | /// Return the authenticated user's subscription status for this app. | |
| 235 | + | /// Returns `active: false` and `None` fields when the user has no subscription | |
| 236 | + | /// (rather than 404) so clients can render a "subscribe" CTA uniformly. | |
| 237 | + | #[utoipa::path(get, path = "/api/v1/sync/subscription", tag = "SyncKit", | |
| 238 | + | responses((status = 200, description = "Subscription status", body = SyncSubscriptionStatusResponse)), | |
| 239 | + | security(("bearer" = [])), | |
| 240 | + | )] | |
| 241 | + | #[tracing::instrument(skip_all, name = "synckit::sync_subscription_status")] | |
| 242 | + | pub(super) async fn sync_subscription_status( | |
| 243 | + | State(state): State<AppState>, | |
| 244 | + | sync_user: SyncUser, | |
| 245 | + | ) -> Result<impl IntoResponse> { | |
| 246 | + | let sub = db::synckit::get_user_app_subscription( | |
| 247 | + | &state.db, | |
| 248 | + | sync_user.user_id, | |
| 249 | + | sync_user.app_id, | |
| 250 | + | ) | |
| 251 | + | .await?; | |
| 252 | + | ||
| 253 | + | let response = match sub { | |
| 254 | + | Some(s) => SyncSubscriptionStatusResponse { | |
| 255 | + | active: s.status == "active", | |
| 256 | + | tier: Some(s.interval), | |
| 257 | + | status: Some(s.status), | |
| 258 | + | storage_limit_bytes: s.storage_limit_bytes, | |
| 259 | + | pending_storage_limit_bytes: s.pending_storage_limit_bytes, | |
| 260 | + | storage_used_bytes: None, | |
| 261 | + | current_period_end: s.current_period_end.map(|t| t.to_rfc3339()), | |
| 262 | + | }, | |
| 263 | + | None => SyncSubscriptionStatusResponse { | |
| 264 | + | active: false, | |
| 265 | + | tier: None, | |
| 266 | + | status: None, | |
| 267 | + | storage_limit_bytes: None, | |
| 268 | + | pending_storage_limit_bytes: None, | |
| 269 | + | storage_used_bytes: None, | |
| 270 | + | current_period_end: None, | |
| 271 | + | }, | |
| 272 | + | }; | |
| 273 | + | ||
| 274 | + | Ok(Json(response)) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// Return the pricing-formula constants for an app. The client uses these to | |
| 278 | + | /// quote a price locally as the user adjusts the cap slider; the same formula | |
| 279 | + | /// is enforced server-side at checkout so the client number is only advisory. | |
| 280 | + | #[utoipa::path(post, path = "/api/v1/sync/app/pricing", tag = "SyncKit", | |
| 281 | + | request_body = AppPricingRequest, | |
| 282 | + | responses((status = 200, description = "Pricing formula", body = AppPricingResponse)), | |
| 283 | + | )] | |
| 284 | + | #[tracing::instrument(skip_all, name = "synckit::get_app_pricing")] | |
| 285 | + | pub(super) async fn get_app_pricing( | |
| 286 | + | State(state): State<AppState>, | |
| 287 | + | Json(req): Json<AppPricingRequest>, | |
| 288 | + | ) -> Result<impl IntoResponse> { | |
| 289 | + | let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key) | |
| 290 | + | .await? | |
| 291 | + | .ok_or(AppError::NotFound)?; | |
| 292 | + | ||
| 293 | + | Ok(Json(AppPricingResponse { | |
| 294 | + | app_name: app.name, | |
| 295 | + | min_charge_cents: payments::MIN_CHARGE_CENTS, | |
| 296 | + | per_gb_tenths_of_cent_per_month: payments::synckit_app_pricing::PER_GB_TENTHS_OF_CENT_PER_MONTH, | |
| 297 | + | annual_multiplier: payments::ANNUAL_MULTIPLIER, | |
| 298 | + | min_cap_bytes: payments::MIN_CAP_BYTES, | |
| 299 | + | max_cap_bytes: payments::MAX_CAP_BYTES, | |
| 300 | + | })) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /// Quote the price for a (cap, interval) pair. Authenticated so clients | |
| 304 | + | /// cannot scrape pricing without an account, but otherwise pure: the result | |
| 305 | + | /// only depends on the formula constants returned by `app/pricing`. | |
| 306 | + | #[utoipa::path(post, path = "/api/v1/sync/subscription/quote", tag = "SyncKit", | |
| 307 | + | request_body = SyncQuoteRequest, | |
| 308 | + | responses((status = 200, description = "Quoted price", body = SyncQuoteResponse)), | |
| 309 | + | security(("bearer" = [])), | |
| 310 | + | )] | |
| 311 | + | #[tracing::instrument(skip_all, name = "synckit::quote_subscription_price")] | |
| 312 | + | pub(super) async fn quote_subscription_price( | |
| 313 | + | State(_state): State<AppState>, | |
| 314 | + | _sync_user: SyncUser, | |
| 315 | + | Json(req): Json<SyncQuoteRequest>, | |
| 316 | + | ) -> Result<impl IntoResponse> { | |
| 317 | + | let interval = SyncBillingInterval::parse(&req.interval)?; | |
| 318 | + | let price_cents = payments::quote_price_cents(req.cap_bytes, interval)?; | |
| 319 | + | Ok(Json(SyncQuoteResponse { | |
| 320 | + | cap_bytes: req.cap_bytes, | |
| 321 | + | interval: interval.as_str().to_string(), | |
| 322 | + | price_cents, | |
| 323 | + | })) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// Create a Stripe Checkout Session for subscribing this user to the app's | |
| 327 | + | /// cloud sync at their chosen storage cap. The `app_sync_subscriptions` row | |
| 328 | + | /// is written by the Stripe webhook on `checkout.session.completed`. | |
| 329 | + | #[utoipa::path(post, path = "/api/v1/sync/subscription/checkout", tag = "SyncKit", | |
| 330 | + | request_body = SyncSubscribeRequest, | |
| 331 | + | responses((status = 200, description = "Checkout URL", body = SyncCheckoutResponse)), | |
| 332 | + | security(("bearer" = [])), | |
| 333 | + | )] | |
| 334 | + | #[tracing::instrument(skip_all, name = "synckit::create_subscription_checkout")] | |
| 335 | + | pub(super) async fn create_subscription_checkout( | |
| 336 | + | State(state): State<AppState>, | |
| 337 | + | sync_user: SyncUser, | |
| 338 | + | Json(req): Json<SyncSubscribeRequest>, | |
| 339 | + | ) -> Result<impl IntoResponse> { | |
| 340 | + | if db::synckit::get_user_app_subscription(&state.db, sync_user.user_id, sync_user.app_id) | |
| 341 | + | .await? | |
| 342 | + | .is_some() | |
| 343 | + | { | |
| 344 | + | return Err(AppError::BadRequest( | |
| 345 | + | "Already subscribed; use the storage-cap endpoint to adjust your cap".to_string(), | |
| 346 | + | )); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | let interval = SyncBillingInterval::parse(&req.interval)?; | |
| 350 | + | let amount_cents = payments::quote_price_cents(req.cap_bytes, interval)?; | |
| 351 | + | ||
| 352 | + | let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id) | |
| 353 | + | .await? | |
| 354 | + | .ok_or(AppError::NotFound)?; | |
| 355 | + | let cap_gib = req.cap_bytes / (1024 * 1024 * 1024); | |
| 356 | + | let product_name = format!("{} cloud sync — {} GiB", app.name, cap_gib); | |
| 357 | + | ||
| 358 | + | let stripe = state | |
| 359 | + | .stripe | |
| 360 | + | .as_ref() | |
| 361 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 362 | + | ||
| 363 | + | let success_url = format!("{}/sync/subscribed", state.config.host_url); | |
| 364 | + | let cancel_url = format!("{}/sync/canceled", state.config.host_url); | |
| 365 | + | ||
| 366 | + | let result = stripe | |
| 367 | + | .create_synckit_app_sub_checkout_session( | |
| 368 | + | &product_name, | |
| 369 | + | amount_cents, | |
| 370 | + | interval.as_str(), | |
| 371 | + | sync_user.user_id, | |
| 372 | + | sync_user.app_id, | |
| 373 | + | interval.as_str(), | |
| 374 | + | Some(req.cap_bytes), | |
| 375 | + | &success_url, | |
| 376 | + | &cancel_url, | |
| 377 | + | ) | |
| 378 | + | .await?; | |
| 379 | + | ||
| 380 | + | let checkout_url = result | |
| 381 | + | .url | |
| 382 | + | .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; | |
| 383 | + | ||
| 384 | + | Ok(Json(SyncCheckoutResponse { checkout_url })) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Queue a storage-cap change to take effect at the next billing cycle. | |
| 388 | + | /// Holding the change for the cycle boundary avoids mid-cycle proration | |
| 389 | + | /// surprises and keeps the user in control of when their bill changes. | |
| 390 | + | /// | |
| 391 | + | /// Updates Stripe first (re-price the subscription item with | |
| 392 | + | /// `proration_behavior=None`), then records the pending cap in the DB. The | |
| 393 | + | /// renewal webhook promotes the pending cap to active when Stripe rolls the | |
| 394 | + | /// period — and because the Stripe price is already updated, the new invoice | |
| 395 | + | /// is at the new price. | |
| 396 | + | #[utoipa::path(post, path = "/api/v1/sync/subscription/storage-cap", tag = "SyncKit", | |
| 397 | + | request_body = SyncCapChangeRequest, | |
| 398 | + | responses((status = 200, description = "Pending cap recorded", body = SyncSubscriptionStatusResponse)), | |
| 399 | + | security(("bearer" = [])), | |
| 400 | + | )] | |
| 401 | + | #[tracing::instrument(skip_all, name = "synckit::queue_storage_cap_change")] | |
| 402 | + | pub(super) async fn queue_storage_cap_change( | |
| 403 | + | State(state): State<AppState>, | |
| 404 | + | sync_user: SyncUser, | |
| 405 | + | Json(req): Json<SyncCapChangeRequest>, | |
| 406 | + | ) -> Result<impl IntoResponse> { | |
| 407 | + | let sub = db::synckit::get_user_app_subscription(&state.db, sync_user.user_id, sync_user.app_id) | |
| 408 | + | .await? | |
| 409 | + | .ok_or_else(|| AppError::BadRequest("No active subscription to adjust".to_string()))?; | |
| 410 | + | ||
| 411 | + | if req.cap_bytes < payments::MIN_CAP_BYTES || req.cap_bytes > payments::MAX_CAP_BYTES { | |
| 412 | + | return Err(AppError::BadRequest(format!( | |
| 413 | + | "Storage cap must be between {} and {} GiB", | |
| 414 | + | payments::MIN_CAP_BYTES / (1024 * 1024 * 1024), | |
| 415 | + | payments::MAX_CAP_BYTES / (1024 * 1024 * 1024) | |
| 416 | + | ))); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | let interval = payments::SyncBillingInterval::parse(&sub.interval)?; | |
| 420 | + | let new_price_cents = payments::quote_price_cents(req.cap_bytes, interval)?; | |
| 421 | + | ||
| 422 | + | let stripe = state | |
| 423 | + | .stripe | |
| 424 | + | .as_ref() | |
| 425 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 426 | + | ||
| 427 | + | let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id) | |
| 428 | + | .await? | |
| 429 | + | .ok_or(AppError::NotFound)?; | |
| 430 | + | let cap_gib = req.cap_bytes / (1024 * 1024 * 1024); | |
| 431 | + | let product_name = format!("{} cloud sync — {} GiB", app.name, cap_gib); | |
| 432 | + | ||
| 433 | + | // Stripe first: if this fails, we want the DB pending cap to stay | |
| 434 | + | // unchanged so the user isn't sitting on an upgrade they never paid for. | |
| 435 | + | stripe | |
| 436 | + | .update_synckit_app_sub_price( | |
| 437 | + | &sub.stripe_subscription_id, | |
| 438 | + | new_price_cents, | |
| 439 | + | interval, | |
| 440 | + | &product_name, | |
| 441 | + | ) | |
| 442 | + | .await?; | |
| 443 | + | ||
| 444 | + | db::synckit::set_pending_storage_cap( | |
| 445 | + | &state.db, | |
| 446 | + | sync_user.user_id, | |
| 447 | + | sync_user.app_id, | |
| 448 | + | req.cap_bytes, | |
| 449 | + | ) | |
| 450 | + | .await?; | |
| 451 | + | ||
| 452 | + | Ok(Json(SyncSubscriptionStatusResponse { | |
| 453 | + | active: sub.status == "active", | |
| 454 | + | tier: Some(sub.interval), | |
| 455 | + | status: Some(sub.status), | |
| 456 | + | storage_limit_bytes: sub.storage_limit_bytes, | |
| 457 | + | pending_storage_limit_bytes: Some(req.cap_bytes), | |
| 458 | + | storage_used_bytes: None, | |
| 459 | + | current_period_end: sub.current_period_end.map(|t| t.to_rfc3339()), | |
| 460 | + | })) | |
| 461 | + | } | |
| 462 | + | ||
| 231 | 463 | // ── Device endpoints (JWT auth) ── | |
| 232 | 464 | ||
| 233 | 465 | /// Register a new sync device (or update an existing one by name). |
| @@ -216,6 +216,14 @@ impl PaymentProvider for MockPaymentProvider { | |||
| 216 | 216 | Ok(()) | |
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | + | async fn update_synckit_app_sub_price(&self, _subscription_id: &str, _new_price_cents: i64, _interval: makenotwork::payments::SyncBillingInterval, _product_name: &str) -> Result<()> { | |
| 220 | + | Ok(()) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | async fn create_synckit_app_sub_checkout_session(&self, _product_name: &str, _amount_cents: i64, _interval: &str, _user_id: makenotwork::db::UserId, _app_id: makenotwork::db::SyncAppId, _tier: &str, _storage_limit_bytes: Option<i64>, _success_url: &str, _cancel_url: &str) -> Result<CheckoutResult> { | |
| 224 | + | Ok(self.next_session()) | |
| 225 | + | } | |
| 226 | + | ||
| 219 | 227 | async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> { | |
| 220 | 228 | Ok(()) | |
| 221 | 229 | } |
| @@ -1506,7 +1506,7 @@ dependencies = [ | |||
| 1506 | 1506 | ||
| 1507 | 1507 | [[package]] | |
| 1508 | 1508 | name = "synckit-client" | |
| 1509 | - | version = "0.3.1" | |
| 1509 | + | version = "0.4.0" | |
| 1510 | 1510 | dependencies = [ | |
| 1511 | 1511 | "argon2", | |
| 1512 | 1512 | "base64", |
| @@ -105,8 +105,9 @@ struct Endpoints { | |||
| 105 | 105 | blobs_download: String, | |
| 106 | 106 | subscription: String, | |
| 107 | 107 | subscription_checkout: String, | |
| 108 | - | subscription_change: String, | |
| 109 | - | app_tiers: String, | |
| 108 | + | subscription_quote: String, | |
| 109 | + | subscription_storage_cap: String, | |
| 110 | + | app_pricing: String, | |
| 110 | 111 | account: String, | |
| 111 | 112 | } | |
| 112 | 113 | ||
| @@ -127,8 +128,9 @@ impl Endpoints { | |||
| 127 | 128 | blobs_download: format!("{base}/api/v1/sync/blobs/download"), | |
| 128 | 129 | subscription: format!("{base}/api/v1/sync/subscription"), | |
| 129 | 130 | subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), | |
| 130 | - | subscription_change: format!("{base}/api/v1/sync/subscription/change"), | |
| 131 | - | app_tiers: format!("{base}/api/v1/sync/app/tiers"), | |
| 131 | + | subscription_quote: format!("{base}/api/v1/sync/subscription/quote"), | |
| 132 | + | subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"), | |
| 133 | + | app_pricing: format!("{base}/api/v1/sync/app/pricing"), | |
| 132 | 134 | account: format!("{base}/api/v1/sync/account"), | |
| 133 | 135 | } | |
| 134 | 136 | } |
| @@ -1,4 +1,11 @@ | |||
| 1 | - | //! Subscription status and checkout methods. | |
| 1 | + | //! Subscription status, pricing-formula quoting, checkout, and storage-cap | |
| 2 | + | //! management for end-user SyncKit subscriptions. | |
| 3 | + | //! | |
| 4 | + | //! The server uses a formula-driven pricing model: there are no fixed tiers, | |
| 5 | + | //! the user picks any storage cap (within a min/max) and the server quotes a | |
| 6 | + | //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`] | |
| 7 | + | //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`] | |
| 8 | + | //! once the user clicks subscribe. | |
| 2 | 9 | ||
| 3 | 10 | use serde::{Deserialize, Serialize}; | |
| 4 | 11 | ||
| @@ -7,52 +14,89 @@ use super::SyncKitClient; | |||
| 7 | 14 | use super::helpers::check_response; | |
| 8 | 15 | ||
| 9 | 16 | /// Subscription status as returned by the server. | |
| 10 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 17 | + | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 11 | 18 | pub struct SubscriptionStatus { | |
| 12 | 19 | /// Whether the user has an active sync subscription for this app. | |
| 13 | 20 | pub active: bool, | |
| 14 | - | /// Subscription tier (if active). | |
| 15 | - | pub tier: Option<String>, | |
| 21 | + | /// Billing interval ("monthly" or "annual"). `None` if no subscription. | |
| 22 | + | /// Kept under the legacy field name `tier` on the wire for SDK compat. | |
| 23 | + | #[serde(rename = "tier")] | |
| 24 | + | pub interval: Option<String>, | |
| 16 | 25 | /// Subscription status string (e.g. "active", "past_due", "canceled"). | |
| 17 | 26 | pub status: Option<String>, | |
| 18 | - | /// Blob storage limit in bytes (AF only). | |
| 27 | + | /// Current blob storage cap, in bytes. | |
| 19 | 28 | pub storage_limit_bytes: Option<i64>, | |
| 20 | - | /// Blob storage used in bytes. | |
| 29 | + | /// A queued cap change that applies at the next billing cycle. `None` | |
| 30 | + | /// when no change is pending. | |
| 31 | + | #[serde(default)] | |
| 32 | + | pub pending_storage_limit_bytes: Option<i64>, | |
| 33 | + | /// Blob storage currently in use, in bytes (when the server has the info). | |
| 21 | 34 | pub storage_used_bytes: Option<i64>, | |
| 22 | 35 | /// ISO 8601 end of current billing period. | |
| 23 | 36 | pub current_period_end: Option<String>, | |
| 24 | 37 | } | |
| 25 | 38 | ||
| 26 | - | /// Information about an available pricing tier. | |
| 39 | + | /// Pricing-formula constants for an app. Clients use these to quote a price | |
| 40 | + | /// locally as a slider moves; the server enforces the same formula at | |
| 41 | + | /// checkout so client-computed prices are advisory only. | |
| 27 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 28 | - | pub struct TierInfo { | |
| 29 | - | /// Tier identifier (e.g. "light", "standard", "large"). | |
| 30 | - | pub id: String, | |
| 31 | - | /// Human-readable label. | |
| 32 | - | pub label: String, | |
| 33 | - | /// Short description (e.g. "10 GB storage"). | |
| 34 | - | pub description: String, | |
| 35 | - | /// Blob storage limit in bytes, if applicable. | |
| 36 | - | pub storage_bytes: Option<i64>, | |
| 37 | - | /// Monthly price in cents. | |
| 38 | - | pub monthly_price_cents: i64, | |
| 39 | - | /// Annual price in cents. | |
| 40 | - | pub annual_price_cents: i64, | |
| 43 | + | pub struct AppPricing { | |
| 44 | + | pub app_name: String, | |
| 45 | + | /// Minimum charge in cents (applies to both monthly and annual). | |
| 46 | + | pub min_charge_cents: i64, | |
| 47 | + | /// Storage rate per GiB per month, in tenths of a cent. Convert with | |
| 48 | + | /// `(gib * per_gb_tenths + 9) / 10` to get cents. | |
| 49 | + | pub per_gb_tenths_of_cent_per_month: i64, | |
| 50 | + | /// Annual price is monthly × this multiplier. | |
| 51 | + | pub annual_multiplier: i64, | |
| 52 | + | pub min_cap_bytes: i64, | |
| 53 | + | pub max_cap_bytes: i64, | |
| 41 | 54 | } | |
| 42 | 55 | ||
| 43 | - | /// Response from the app tiers endpoint. | |
| 44 | - | #[derive(Debug, Deserialize)] | |
| 45 | - | struct AppTiersResponse { | |
| 46 | - | #[allow(dead_code)] | |
| 47 | - | app_name: String, | |
| 48 | - | tiers: Vec<TierInfo>, | |
| 56 | + | impl AppPricing { | |
| 57 | + | /// Compute the price in cents for a given cap and interval. Mirrors the | |
| 58 | + | /// server-side formula so client-side previews match what the user will | |
| 59 | + | /// actually be charged. | |
| 60 | + | pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> i64 { | |
| 61 | + | let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes); | |
| 62 | + | let gib = cap_bytes_to_gib_ceil(cap_bytes); | |
| 63 | + | let storage_monthly = (gib * self.per_gb_tenths_of_cent_per_month + 9) / 10; | |
| 64 | + | let monthly = storage_monthly.max(self.min_charge_cents); | |
| 65 | + | match interval { | |
| 66 | + | BillingInterval::Monthly => monthly, | |
| 67 | + | BillingInterval::Annual => monthly * self.annual_multiplier, | |
| 68 | + | } | |
| 69 | + | } | |
| 49 | 70 | } | |
| 50 | 71 | ||
| 51 | - | /// Request body for creating a checkout session or changing tier. | |
| 52 | - | #[derive(Debug, Serialize)] | |
| 53 | - | struct TierRequest<'a> { | |
| 54 | - | tier: &'a str, | |
| 55 | - | interval: &'a str, | |
| 72 | + | fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 { | |
| 73 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 74 | + | (cap_bytes + GIB - 1) / GIB | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Billing interval for SyncKit subscriptions. | |
| 78 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 79 | + | pub enum BillingInterval { | |
| 80 | + | Monthly, | |
| 81 | + | Annual, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | impl BillingInterval { | |
| 85 | + | pub fn as_str(self) -> &'static str { | |
| 86 | + | match self { | |
| 87 | + | Self::Monthly => "monthly", | |
| 88 | + | Self::Annual => "annual", | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Parse from the wire string. Falls back to `Monthly` for unknown | |
| 93 | + | /// values rather than erroring — defensive against forward-compat tags. | |
| 94 | + | pub fn from_str(s: &str) -> Self { | |
| 95 | + | match s { | |
| 96 | + | "annual" => Self::Annual, | |
| 97 | + | _ => Self::Monthly, | |
| 98 | + | } | |
| 99 | + | } | |
| 56 | 100 | } | |
| 57 | 101 | ||
| 58 | 102 | /// Response from creating a checkout session. | |
| @@ -71,23 +115,62 @@ pub struct AccountInfo { | |||
| 71 | 115 | pub username: String, | |
| 72 | 116 | } | |
| 73 | 117 | ||
| 74 | - | impl SyncKitClient { | |
| 75 | - | /// Fetch available pricing tiers for this app. | |
| 76 | - | /// | |
| 77 | - | /// Authenticated by API key only (no JWT needed). Can be called before | |
| 78 | - | /// the user has logged in, allowing the app to display pricing upfront. | |
| 79 | - | pub async fn get_available_tiers(&self) -> Result<Vec<TierInfo>> { | |
| 80 | - | let body = serde_json::json!({ "api_key": self.config.api_key }); | |
| 118 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 119 | + | pub struct PriceQuote { | |
| 120 | + | pub cap_bytes: i64, | |
| 121 | + | pub interval: String, | |
| 122 | + | pub price_cents: i64, | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[derive(Debug, Serialize)] | |
| 126 | + | struct AppPricingRequest<'a> { | |
| 127 | + | api_key: &'a str, | |
| 128 | + | } | |
| 81 | 129 | ||
| 130 | + | #[derive(Debug, Serialize)] | |
| 131 | + | struct QuoteRequest { | |
| 132 | + | cap_bytes: i64, | |
| 133 | + | interval: &'static str, | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[derive(Debug, Serialize)] | |
| 137 | + | struct CheckoutRequest { | |
| 138 | + | cap_bytes: i64, | |
| 139 | + | interval: &'static str, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[derive(Debug, Serialize)] | |
| 143 | + | struct CapChangeRequest { | |
| 144 | + | cap_bytes: i64, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | impl SyncKitClient { | |
| 148 | + | /// Fetch the pricing-formula constants for this app. No JWT needed; the | |
| 149 | + | /// app's API key is sent in the body. Safe to call before login so the | |
| 150 | + | /// UI can show pricing on the marketing/onboarding view. | |
| 151 | + | pub async fn get_app_pricing(&self) -> Result<AppPricing> { | |
| 82 | 152 | let resp = self.http | |
| 83 | - | .post(&self.endpoints.app_tiers) | |
| 84 | - | .json(&body) | |
| 153 | + | .post(&self.endpoints.app_pricing) | |
| 154 | + | .json(&AppPricingRequest { api_key: &self.config.api_key }) | |
| 85 | 155 | .send() | |
| 86 | 156 | .await?; | |
| 157 | + | let resp = check_response(resp).await?; | |
| 158 | + | Ok(resp.json().await?) | |
| 159 | + | } | |
| 87 | 160 | ||
| 161 | + | /// Server-side price quote for a (cap, interval). Use this to confirm | |
| 162 | + | /// the number you display matches what Stripe will charge; the result is | |
| 163 | + | /// authoritative. | |
| 164 | + | pub async fn quote_price(&self, cap_bytes: i64, interval: BillingInterval) -> Result<PriceQuote> { | |
| 165 | + | let token = self.require_token()?; | |
| 166 | + | let resp = self.http | |
| 167 | + | .post(&self.endpoints.subscription_quote) | |
| 168 | + | .bearer_auth(token.as_str()) | |
| 169 | + | .json(&QuoteRequest { cap_bytes, interval: interval.as_str() }) | |
| 170 | + | .send() | |
| 171 | + | .await?; | |
| 88 | 172 | let resp = check_response(resp).await?; | |
| 89 | - | let tiers_resp: AppTiersResponse = resp.json().await?; | |
| 90 | - | Ok(tiers_resp.tiers) | |
| 173 | + | Ok(resp.json().await?) | |
| 91 | 174 | } | |
| 92 | 175 | ||
| 93 | 176 | /// Fetch the authenticated user's email and username. | |
| @@ -125,28 +208,20 @@ impl SyncKitClient { | |||
| 125 | 208 | Ok(status) | |
| 126 | 209 | } | |
| 127 | 210 | ||
| 128 | - | /// Create a Stripe Checkout session for subscribing to sync. | |
| 129 | - | /// | |
| 130 | - | /// Returns a URL that should be opened in the user's browser. | |
| 131 | - | /// After payment, the server creates the subscription record and | |
| 132 | - | /// subsequent `get_subscription_status()` calls will return `active: true`. | |
| 133 | - | /// | |
| 134 | - | /// # Arguments | |
| 135 | - | /// * `tier` - "standard" for GO/BB, "light"/"standard"/"large" for AF | |
| 136 | - | /// * `interval` - "monthly" or "annual" | |
| 211 | + | /// Create a Stripe Checkout session for subscribing to cloud sync at the | |
| 212 | + | /// chosen storage cap. Returns a URL that should be opened in the user's | |
| 213 | + | /// browser. | |
| 137 | 214 | pub async fn create_subscription_checkout( | |
| 138 | 215 | &self, | |
| 139 | - | tier: &str, | |
| 140 | - | interval: &str, | |
| 216 | + | cap_bytes: i64, | |
| 217 | + | interval: BillingInterval, | |
| 141 | 218 | ) -> Result<CheckoutResponse> { | |
| 142 | 219 | let token = self.require_token()?; | |
| 143 | 220 | ||
| 144 | - | let body = TierRequest { tier, interval }; | |
| 145 | - | ||
| 146 | 221 | let resp = self.http | |
| 147 | 222 | .post(&self.endpoints.subscription_checkout) | |
| 148 | 223 | .bearer_auth(token.as_str()) | |
| 149 | - | .json(&body) | |
| 224 | + | .json(&CheckoutRequest { cap_bytes, interval: interval.as_str() }) | |
| 150 | 225 | .send() | |
| 151 | 226 | .await?; | |
| 152 | 227 | ||
| @@ -155,26 +230,16 @@ impl SyncKitClient { | |||
| 155 | 230 | Ok(checkout) | |
| 156 | 231 | } | |
| 157 | 232 | ||
| 158 | - | /// Change the tier of an existing sync subscription. | |
| 159 | - | /// | |
| 160 | - | /// Stripe prorates the charge. Returns updated subscription status. | |
| 161 | - | /// | |
| 162 | - | /// # Arguments | |
| 163 | - | /// * `tier` - "light", "standard", or "large" | |
| 164 | - | /// * `interval` - "monthly" or "annual" | |
| 165 | - | pub async fn change_subscription_tier( | |
| 166 | - | &self, | |
| 167 | - | tier: &str, | |
| 168 | - | interval: &str, | |
| 169 | - | ) -> Result<SubscriptionStatus> { | |
| 233 | + | /// Queue a storage-cap change that takes effect at the next billing | |
| 234 | + | /// cycle. Returns the updated subscription status with the new cap in | |
| 235 | + | /// `pending_storage_limit_bytes`. | |
| 236 | + | pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> { | |
| 170 | 237 | let token = self.require_token()?; | |
| 171 | 238 | ||
| 172 | - | let body = TierRequest { tier, interval }; | |
| 173 | - | ||
| 174 | 239 | let resp = self.http | |
| 175 | - | .post(&self.endpoints.subscription_change) | |
| 240 | + | .post(&self.endpoints.subscription_storage_cap) | |
| 176 | 241 | .bearer_auth(token.as_str()) | |
| 177 | - | .json(&body) | |
| 242 | + | .json(&CapChangeRequest { cap_bytes }) | |
| 178 | 243 | .send() | |
| 179 | 244 | .await?; | |
| 180 | 245 |