max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 files changed,
+428 insertions,
-10 deletions
| @@ -142,6 +142,30 @@ | |||
| 142 | 142 | Ok(exists) | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | + | /// Mark a Fan+ subscription as scheduled to cancel at period end (or undo). | |
| 146 | + | /// | |
| 147 | + | /// Sets the local flag; Stripe is the source of truth and re-asserts it via | |
| 148 | + | /// the `customer.subscription.updated` webhook. Called from the dashboard | |
| 149 | + | /// Cancel/Resume buttons and from the webhook handler. | |
| 150 | + | #[tracing::instrument(skip_all)] | |
| 151 | + | pub async fn set_cancel_at_period_end( | |
| 152 | + | pool: &PgPool, | |
| 153 | + | stripe_subscription_id: &str, | |
| 154 | + | cancel: bool, | |
| 155 | + | ) -> Result<Option<DbFanPlusSubscription>> { | |
| 156 | + | let sub = sqlx::query_as::<_, DbFanPlusSubscription>( | |
| 157 | + | "UPDATE fan_plus_subscriptions | |
| 158 | + | SET cancel_at_period_end = $2 | |
| 159 | + | WHERE stripe_subscription_id = $1 | |
| 160 | + | RETURNING *", | |
| 161 | + | ) | |
| 162 | + | .bind(stripe_subscription_id) | |
| 163 | + | .bind(cancel) | |
| 164 | + | .fetch_optional(pool) | |
| 165 | + | .await?; | |
| 166 | + | Ok(sub) | |
| 167 | + | } | |
| 168 | + | ||
| 145 | 169 | /// Get a user's Fan+ subscription (any status). | |
| 146 | 170 | #[tracing::instrument(skip_all)] | |
| 147 | 171 | pub async fn get_fan_plus_by_user( |
| @@ -348,6 +348,79 @@ | |||
| 348 | 348 | Ok(()) | |
| 349 | 349 | } | |
| 350 | 350 | ||
| 351 | + | /// Set or clear `cancel_at_period_end` on a platform-level subscription | |
| 352 | + | /// (Fan+, creator tier). Used by Fan+ self-service cancel/resume on the | |
| 353 | + | /// dashboard. No `Stripe-Account` header — the subscription belongs to | |
| 354 | + | /// the platform. | |
| 355 | + | #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")] | |
| 356 | + | pub async fn set_platform_cancel_at_period_end( | |
| 357 | + | &self, | |
| 358 | + | stripe_sub_id: &str, | |
| 359 | + | cancel: bool, | |
| 360 | + | ) -> Result<()> { | |
| 361 | + | let url = format!("https://api.stripe.com/v1/subscriptions/{}", stripe_sub_id); | |
| 362 | + | let resp = reqwest::Client::new() | |
| 363 | + | .post(&url) | |
| 364 | + | .header("Authorization", format!("Bearer {}", self.config.secret_key)) | |
| 365 | + | .form(&[("cancel_at_period_end", if cancel { "true" } else { "false" })]) | |
| 366 | + | .timeout(std::time::Duration::from_secs(30)) | |
| 367 | + | .send() | |
| 368 | + | .await | |
| 369 | + | .map_err(|e| { | |
| 370 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end"); | |
| 371 | + | AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")) | |
| 372 | + | })?; | |
| 373 | + | ||
| 374 | + | if !resp.status().is_success() { | |
| 375 | + | let body = resp.text().await.unwrap_or_default(); | |
| 376 | + | tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, body = %body, "Stripe set platform cancel_at_period_end returned error"); | |
| 377 | + | return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | Ok(()) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | /// Create a Stripe Billing Portal session for a customer. The returned URL | |
| 384 | + | /// is a Stripe-hosted page where the customer can update payment methods, | |
| 385 | + | /// view invoices, and (if portal config permits) cancel subscriptions. | |
| 386 | + | /// | |
| 387 | + | /// Requires Customer Portal to be configured in the Stripe dashboard. | |
| 388 | + | #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")] | |
| 389 | + | pub async fn create_billing_portal_session( | |
| 390 | + | &self, | |
| 391 | + | stripe_customer_id: &str, | |
| 392 | + | return_url: &str, | |
| 393 | + | ) -> Result<String> { | |
| 394 | + | let resp = reqwest::Client::new() | |
| 395 | + | .post("https://api.stripe.com/v1/billing_portal/sessions") | |
| 396 | + | .header("Authorization", format!("Bearer {}", self.config.secret_key)) | |
| 397 | + | .form(&[ | |
| 398 | + | ("customer", stripe_customer_id), | |
| 399 | + | ("return_url", return_url), | |
| 400 | + | ]) | |
| 401 | + | .timeout(std::time::Duration::from_secs(30)) | |
| 402 | + | .send() | |
| 403 | + | .await | |
| 404 | + | .map_err(|e| { | |
| 405 | + | tracing::error!(error = ?e, "failed to create billing portal session"); | |
| 406 | + | AppError::Internal(anyhow::anyhow!("Failed to create billing portal session")) | |
| 407 | + | })?; | |
| 408 | + | ||
| 409 | + | if !resp.status().is_success() { | |
| 410 | + | let body = resp.text().await.unwrap_or_default(); | |
| 411 | + | tracing::error!(body = %body, "Stripe billing portal returned error"); | |
| 412 | + | return Err(AppError::Internal(anyhow::anyhow!("Failed to create billing portal session"))); | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | #[derive(serde::Deserialize)] | |
| 416 | + | struct PortalResp { url: String } | |
| 417 | + | let parsed: PortalResp = resp.json().await.map_err(|e| { | |
| 418 | + | tracing::error!(error = ?e, "billing portal parse failed"); | |
| 419 | + | AppError::Internal(anyhow::anyhow!("Billing portal response parse error")) | |
| 420 | + | })?; | |
| 421 | + | Ok(parsed.url) | |
| 422 | + | } | |
| 423 | + | ||
| 351 | 424 | /// Set or clear `cancel_at_period_end` on a subscription (connected account). | |
| 352 | 425 | /// | |
| 353 | 426 | /// Used for creator pause (cancel=true: fans keep access through their paid |
| @@ -84,6 +84,10 @@ | |||
| 84 | 84 | async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()>; | |
| 85 | 85 | /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account. | |
| 86 | 86 | async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>; | |
| 87 | + | /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier). | |
| 88 | + | async fn set_platform_cancel_at_period_end(&self, stripe_sub_id: &str, cancel: bool) -> crate::error::Result<()>; | |
| 89 | + | /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to. | |
| 90 | + | async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String>; | |
| 87 | 91 | ||
| 88 | 92 | // Refunds | |
| 89 | 93 | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| @@ -192,6 +196,14 @@ | |||
| 192 | 196 | StripeClient::cancel_platform_subscription(self, stripe_sub_id).await | |
| 193 | 197 | } | |
| 194 | 198 | ||
| 199 | + | async fn set_platform_cancel_at_period_end(&self, stripe_sub_id: &str, cancel: bool) -> crate::error::Result<()> { | |
| 200 | + | StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String> { | |
| 204 | + | StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await | |
| 205 | + | } | |
| 206 | + | ||
| 195 | 207 | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()> { | |
| 196 | 208 | StripeClient::create_refund(self, payment_intent_id, connected_account_id).await | |
| 197 | 209 | } |
| @@ -205,6 +205,25 @@ | |||
| 205 | 205 | pub moderation_history: Vec<ModerationActionView>, | |
| 206 | 206 | /// Whether this creator has voluntarily paused their account. | |
| 207 | 207 | pub creator_paused: bool, | |
| 208 | + | /// Compact Fan+ pane state for the account tab. `None` = the user is not | |
| 209 | + | /// a Fan+ subscriber; the tab renders a one-line "Support the platform" | |
| 210 | + | /// link instead of the active pane. | |
| 211 | + | pub fan_plus: Option<FanPlusPaneView>, | |
| 212 | + | /// CSRF token for the Fan+ cancel/resume/billing-portal form posts. The | |
| 213 | + | /// rest of the tab uses HTMX (which sends `X-CSRF-Token` automatically), | |
| 214 | + | /// but these are vanilla form POSTs that redirect. | |
| 215 | + | pub csrf_token: super::CsrfTokenOption, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Compact dashboard view of the user's Fan+ subscription. Lives under the | |
| 219 | + | /// account tab; intentionally small, no upsell copy. | |
| 220 | + | pub struct FanPlusPaneView { | |
| 221 | + | /// Current period end as a formatted date (e.g., "Dec 14, 2026"). `None` | |
| 222 | + | /// when Stripe hasn't reported a period yet (rare; just after checkout). | |
| 223 | + | pub period_end: Option<String>, | |
| 224 | + | /// Subscription is scheduled to cancel at `period_end`. Drives the Resume | |
| 225 | + | /// affordance. | |
| 226 | + | pub cancel_at_period_end: bool, | |
| 208 | 227 | } | |
| 209 | 228 | ||
| 210 | 229 | /// View model for a moderation action displayed on the settings page. |