max / makenotwork
16 files changed,
+428 insertions,
-10 deletions
| @@ -0,0 +1,9 @@ | |||
| 1 | + | -- Fan+ self-service cancellation: track "cancel pending" distinctly from | |
| 2 | + | -- "canceled" so the dashboard can show "active until X (cancel scheduled)". | |
| 3 | + | -- | |
| 4 | + | -- Set when the user clicks Cancel; cleared if they click Resume before the | |
| 5 | + | -- period ends. Stripe's `customer.subscription.updated` webhook keeps this | |
| 6 | + | -- in sync, so external changes (e.g., via the customer portal) flow back. | |
| 7 | + | ||
| 8 | + | ALTER TABLE fan_plus_subscriptions | |
| 9 | + | ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE; |
| @@ -142,6 +142,30 @@ pub async fn is_fan_plus_active(pool: &PgPool, user_id: UserId) -> Result<bool> | |||
| 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( |
| @@ -164,6 +164,10 @@ pub struct DbFanPlusSubscription { | |||
| 164 | 164 | pub created_at: DateTime<Utc>, | |
| 165 | 165 | /// When the subscription was canceled. | |
| 166 | 166 | pub canceled_at: Option<DateTime<Utc>>, | |
| 167 | + | /// Whether the subscription is scheduled to cancel at `current_period_end`. | |
| 168 | + | /// True after the user clicks Cancel on the dashboard or in Stripe's | |
| 169 | + | /// customer portal; cleared if they click Resume before the period ends. | |
| 170 | + | pub cancel_at_period_end: bool, | |
| 167 | 171 | } | |
| 168 | 172 | ||
| 169 | 173 | /// A creator tier subscription (platform billing for creator features). |
| @@ -348,6 +348,79 @@ impl StripeClient { | |||
| 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 @@ pub trait PaymentProvider: Send + Sync { | |||
| 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 @@ impl PaymentProvider for StripeClient { | |||
| 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 | } |
| @@ -211,6 +211,16 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account( | |||
| 211 | 211 | }) | |
| 212 | 212 | .collect(); | |
| 213 | 213 | ||
| 214 | + | let fan_plus = db::fan_plus::get_fan_plus_by_user(&state.db, session_user.id) | |
| 215 | + | .await? | |
| 216 | + | .filter(|sub| matches!(sub.status, db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue)) | |
| 217 | + | .map(|sub| crate::templates::FanPlusPaneView { | |
| 218 | + | period_end: sub.current_period_end.map(|d| d.format("%b %-d, %Y").to_string()), | |
| 219 | + | cancel_at_period_end: sub.cancel_at_period_end, | |
| 220 | + | }); | |
| 221 | + | ||
| 222 | + | let csrf_token = crate::csrf::get_or_create_token(&session).await.ok(); | |
| 223 | + | ||
| 214 | 224 | Ok(UserAccountTabTemplate { | |
| 215 | 225 | user, | |
| 216 | 226 | sessions, | |
| @@ -220,6 +230,8 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account( | |||
| 220 | 230 | moderation_active, | |
| 221 | 231 | moderation_history, | |
| 222 | 232 | creator_paused: db_user.is_creator_paused(), | |
| 233 | + | fan_plus, | |
| 234 | + | csrf_token, | |
| 223 | 235 | }) | |
| 224 | 236 | } | |
| 225 | 237 |
| @@ -10,7 +10,8 @@ pub(in crate::routes::stripe) use item::create_checkout; | |||
| 10 | 10 | pub(crate) use item::grant_bundle_items; | |
| 11 | 11 | pub(in crate::routes::stripe) use project::create_project_checkout; | |
| 12 | 12 | pub(in crate::routes::stripe) use subscriptions::{ | |
| 13 | - | create_creator_tier_checkout, create_fan_plus_checkout, create_subscription_checkout, | |
| 13 | + | cancel_fan_plus, create_creator_tier_checkout, create_fan_plus_checkout, | |
| 14 | + | create_subscription_checkout, open_billing_portal, resume_fan_plus, | |
| 14 | 15 | }; | |
| 15 | 16 | pub(in crate::routes::stripe) use tips::create_tip_checkout; | |
| 16 | 17 | pub(in crate::routes::stripe) use cart::{create_cart_checkout, create_cart_checkout_all}; |
| @@ -51,6 +51,77 @@ pub(in crate::routes::stripe) async fn create_fan_plus_checkout( | |||
| 51 | 51 | Ok(Redirect::to(&checkout_url).into_response()) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | /// POST /stripe/fan-plus/cancel -- Schedule Fan+ to cancel at period end. | |
| 55 | + | /// | |
| 56 | + | /// Self-service: leaves the subscription active through the current paid | |
| 57 | + | /// period (no proration). The user can resume before period end to undo. | |
| 58 | + | /// Stripe's `customer.subscription.updated` webhook keeps the local flag in | |
| 59 | + | /// sync if the user later cancels via the customer portal instead. | |
| 60 | + | #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")] | |
| 61 | + | pub(in crate::routes::stripe) async fn cancel_fan_plus( | |
| 62 | + | State(state): State<AppState>, | |
| 63 | + | AuthUser(user): AuthUser, | |
| 64 | + | ) -> Result<Redirect> { | |
| 65 | + | let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) | |
| 66 | + | .await? | |
| 67 | + | .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?; | |
| 68 | + | ||
| 69 | + | let stripe = state.stripe.as_ref() | |
| 70 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 71 | + | ||
| 72 | + | stripe | |
| 73 | + | .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true) | |
| 74 | + | .await?; | |
| 75 | + | db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, true).await?; | |
| 76 | + | ||
| 77 | + | Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled")) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// POST /stripe/fan-plus/resume -- Undo a scheduled cancellation. | |
| 81 | + | #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")] | |
| 82 | + | pub(in crate::routes::stripe) async fn resume_fan_plus( | |
| 83 | + | State(state): State<AppState>, | |
| 84 | + | AuthUser(user): AuthUser, | |
| 85 | + | ) -> Result<Redirect> { | |
| 86 | + | let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) | |
| 87 | + | .await? | |
| 88 | + | .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; | |
| 89 | + | ||
| 90 | + | let stripe = state.stripe.as_ref() | |
| 91 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 92 | + | ||
| 93 | + | stripe | |
| 94 | + | .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false) | |
| 95 | + | .await?; | |
| 96 | + | db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, false).await?; | |
| 97 | + | ||
| 98 | + | Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed")) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// POST /stripe/billing-portal -- Open the Stripe customer portal. | |
| 102 | + | /// | |
| 103 | + | /// Stripe-hosted: handles payment method updates, invoice history, and | |
| 104 | + | /// (if configured in the dashboard) subscription cancellation. Routes from | |
| 105 | + | /// the dashboard Fan+ pane. | |
| 106 | + | #[tracing::instrument(skip_all, name = "stripe::billing_portal")] | |
| 107 | + | pub(in crate::routes::stripe) async fn open_billing_portal( | |
| 108 | + | State(state): State<AppState>, | |
| 109 | + | AuthUser(user): AuthUser, | |
| 110 | + | ) -> Result<Redirect> { | |
| 111 | + | let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) | |
| 112 | + | .await? | |
| 113 | + | .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; | |
| 114 | + | ||
| 115 | + | let stripe = state.stripe.as_ref() | |
| 116 | + | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 117 | + | ||
| 118 | + | let return_url = format!("{}/dashboard?tab=account", state.config.host_url); | |
| 119 | + | let url = stripe | |
| 120 | + | .create_billing_portal_session(&sub.stripe_customer_id, &return_url) | |
| 121 | + | .await?; | |
| 122 | + | Ok(Redirect::to(&url)) | |
| 123 | + | } | |
| 124 | + | ||
| 54 | 125 | /// Form data for creator tier checkout. | |
| 55 | 126 | #[derive(Debug, Deserialize)] | |
| 56 | 127 | pub(in crate::routes::stripe) struct CreatorTierForm { |
| @@ -27,6 +27,9 @@ pub fn stripe_routes() -> Router<AppState> { | |||
| 27 | 27 | .route("/stripe/connect/refresh", get(connect::stripe_connect_refresh)) | |
| 28 | 28 | // Checkout flow (idempotency handled by global middleware in metrics.rs) | |
| 29 | 29 | .route("/stripe/fan-plus", post(checkout::create_fan_plus_checkout)) | |
| 30 | + | .route("/stripe/fan-plus/cancel", post(checkout::cancel_fan_plus)) | |
| 31 | + | .route("/stripe/fan-plus/resume", post(checkout::resume_fan_plus)) | |
| 32 | + | .route("/stripe/billing-portal", post(checkout::open_billing_portal)) | |
| 30 | 33 | .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout)) | |
| 31 | 34 | .route("/stripe/checkout/{item_id}", post(checkout::create_checkout)) | |
| 32 | 35 | .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout)) |
| @@ -27,6 +27,12 @@ pub(super) async fn handle_subscription_updated( | |||
| 27 | 27 | let period_end = stripe_timestamp(sub.current_period_end); | |
| 28 | 28 | db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan plus period")?; | |
| 29 | 29 | ||
| 30 | + | // Keep the dashboard flag in sync with Stripe — covers cancellation | |
| 31 | + | // initiated via the customer portal as well as our dashboard route. | |
| 32 | + | db::fan_plus::set_cancel_at_period_end(&state.db, &stripe_sub_id, sub.cancel_at_period_end) | |
| 33 | + | .await | |
| 34 | + | .context("sync fan plus cancel_at_period_end")?; | |
| 35 | + | ||
| 30 | 36 | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 31 | 37 | &state.db, None, event_id, "customer.subscription.updated.fan_plus", | |
| 32 | 38 | &serde_json::json!({"status": status_str}), |
| @@ -205,6 +205,25 @@ pub struct UserAccountTabTemplate { | |||
| 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. |
| @@ -96,11 +96,11 @@ | |||
| 96 | 96 | {% if let Some(token) = csrf_token %} | |
| 97 | 97 | <input type="hidden" name="_csrf" value="{{ token }}"> | |
| 98 | 98 | {% endif %} | |
| 99 | - | <button type="submit" class="subscribe-btn">Subscribe to Fan+</button> | |
| 99 | + | <button type="submit" class="subscribe-btn">Join Fan+</button> | |
| 100 | 100 | </form> | |
| 101 | 101 | </div> | |
| 102 | 102 | {% else %} | |
| 103 | - | <p class="login-link"><a href="/login">Log in</a> to subscribe.</p> | |
| 103 | + | <p class="login-link"><a href="/login">Log in</a> to join.</p> | |
| 104 | 104 | {% endif %} | |
| 105 | 105 | {% endif %} | |
| 106 | 106 | </div> |
| @@ -52,7 +52,7 @@ | |||
| 52 | 52 | <h2 class="section-label">You keep</h2> | |
| 53 | 53 | <div class="mnw-summary"> | |
| 54 | 54 | <div class="mnw-keep" id="mnw-keep">$940.00</div> | |
| 55 | - | <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and $10/mo subscription</div> | |
| 55 | + | <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and $10/mo membership</div> | |
| 56 | 56 | </div> | |
| 57 | 57 | </div> | |
| 58 | 58 | ||
| @@ -230,13 +230,13 @@ | |||
| 230 | 230 | } | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | - | // MNW net: revenue minus processing fees minus monthly subscription | |
| 233 | + | // MNW net: revenue minus processing fees minus monthly membership | |
| 234 | 234 | var mnwNet = stripeNet(revenue) - tierCost; | |
| 235 | 235 | if (revenue <= 0) mnwNet = -tierCost; | |
| 236 | 236 | ||
| 237 | 237 | document.getElementById('mnw-keep').textContent = fmt(Math.max(0, mnwNet)); | |
| 238 | 238 | document.getElementById('mnw-detail').textContent = | |
| 239 | - | 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo subscription'; | |
| 239 | + | 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo membership'; | |
| 240 | 240 | ||
| 241 | 241 | // Build comparison rows sorted by net (descending) | |
| 242 | 242 | var rows = []; |
| @@ -121,6 +121,40 @@ | |||
| 121 | 121 | <input type="text" id="username" value="{{ user.username }}" disabled> | |
| 122 | 122 | <div class="hint">Username cannot be changed</div> | |
| 123 | 123 | </div> | |
| 124 | + | <div class="form-group"> | |
| 125 | + | <label>Fan+ membership</label> | |
| 126 | + | {% if let Some(fp) = fan_plus %} | |
| 127 | + | <div class="hint" style="margin-bottom: 0.5rem;"> | |
| 128 | + | {% if fp.cancel_at_period_end %} | |
| 129 | + | Active{% if let Some(end) = fp.period_end %} until {{ end }}{% endif %} — cancellation scheduled. | |
| 130 | + | {% else %} | |
| 131 | + | Active{% if let Some(end) = fp.period_end %} until {{ end }}{% endif %}. | |
| 132 | + | {% endif %} | |
| 133 | + | </div> | |
| 134 | + | <div class="form-inline-row"> | |
| 135 | + | {% if fp.cancel_at_period_end %} | |
| 136 | + | <form method="post" action="/stripe/fan-plus/resume" style="display:inline"> | |
| 137 | + | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 138 | + | <button type="submit" class="secondary small">Resume</button> | |
| 139 | + | </form> | |
| 140 | + | {% else %} | |
| 141 | + | <form method="post" action="/stripe/fan-plus/cancel" style="display:inline" | |
| 142 | + | hx-confirm="Cancel Fan+ at the end of the current billing period?"> | |
| 143 | + | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 144 | + | <button type="submit" class="secondary small">Cancel</button> | |
| 145 | + | </form> | |
| 146 | + | {% endif %} | |
| 147 | + | <form method="post" action="/stripe/billing-portal" style="display:inline"> | |
| 148 | + | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 149 | + | <button type="submit" class="secondary small">Manage billing</button> | |
| 150 | + | </form> | |
| 151 | + | </div> | |
| 152 | + | {% else %} | |
| 153 | + | <div class="hint"> | |
| 154 | + | Not subscribed. <a href="/fan-plus">Learn about Fan+</a>. | |
| 155 | + | </div> | |
| 156 | + | {% endif %} | |
| 157 | + | </div> | |
| 124 | 158 | </div> | |
| 125 | 159 | ||
| 126 | 160 | <div class="section-group-label">Preferences</div> |
| @@ -192,6 +192,15 @@ impl PaymentProvider for MockPaymentProvider { | |||
| 192 | 192 | Ok(()) | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | + | async fn set_platform_cancel_at_period_end(&self, _stripe_sub_id: &str, _cancel: bool) -> Result<()> { | |
| 196 | + | Ok(()) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | async fn create_billing_portal_session(&self, _stripe_customer_id: &str, return_url: &str) -> Result<String> { | |
| 200 | + | // Echo a deterministic URL so tests can assert the redirect target. | |
| 201 | + | Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url))) | |
| 202 | + | } | |
| 203 | + | ||
| 195 | 204 | async fn create_refund(&self, _payment_intent_id: &str, _connected_account_id: &str) -> Result<()> { | |
| 196 | 205 | Ok(()) | |
| 197 | 206 | } |
| @@ -15,7 +15,7 @@ async fn fan_plus_page_renders_for_anonymous() { | |||
| 15 | 15 | assert_eq!(resp.status, 200); | |
| 16 | 16 | assert!(resp.text.contains("Fan+")); | |
| 17 | 17 | assert!(resp.text.contains("Log in")); | |
| 18 | - | assert!(!resp.text.contains("Subscribe to Fan+")); | |
| 18 | + | assert!(!resp.text.contains("Join Fan+")); | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | #[tokio::test] | |
| @@ -25,7 +25,7 @@ async fn fan_plus_page_renders_subscribe_button_for_user() { | |||
| 25 | 25 | ||
| 26 | 26 | let resp = h.client.get("/fan-plus").await; | |
| 27 | 27 | assert_eq!(resp.status, 200); | |
| 28 | - | assert!(resp.text.contains("Subscribe to Fan+")); | |
| 28 | + | assert!(resp.text.contains("Join Fan+")); | |
| 29 | 29 | assert!(!resp.text.contains("membership is active")); | |
| 30 | 30 | } | |
| 31 | 31 | ||
| @@ -47,7 +47,7 @@ async fn fan_plus_page_shows_active_status_for_subscriber() { | |||
| 47 | 47 | let resp = h.client.get("/fan-plus").await; | |
| 48 | 48 | assert_eq!(resp.status, 200); | |
| 49 | 49 | assert!(resp.text.contains("membership is active")); | |
| 50 | - | assert!(!resp.text.contains("Subscribe to Fan+")); | |
| 50 | + | assert!(!resp.text.contains("Join Fan+")); | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | #[tokio::test] | |
| @@ -406,6 +406,147 @@ async fn canceled_fan_plus_not_shown_as_active() { | |||
| 406 | 406 | let resp = h.client.get("/fan-plus").await; | |
| 407 | 407 | assert_eq!(resp.status, 200); | |
| 408 | 408 | // Should show subscribe button, not active status | |
| 409 | - | assert!(resp.text.contains("Subscribe to Fan+")); | |
| 409 | + | assert!(resp.text.contains("Join Fan+")); | |
| 410 | 410 | assert!(!resp.text.contains("membership is active")); | |
| 411 | 411 | } | |
| 412 | + | ||
| 413 | + | // ── Self-service cancel / resume / billing portal ── | |
| 414 | + | // | |
| 415 | + | // These routes underpin the small dashboard pane added in this step. The | |
| 416 | + | // MockPaymentProvider ack's the Stripe calls so we only need to check our DB | |
| 417 | + | // state and HTTP responses. | |
| 418 | + | ||
| 419 | + | async fn seed_active_fan_plus(h: &TestHarness, user_id: makenotwork::db::UserId, sub_id: &str) { | |
| 420 | + | sqlx::query( | |
| 421 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 422 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \ | |
| 423 | + | VALUES ($1, $2, $3, 'active', NOW() + interval '30 days')", | |
| 424 | + | ) | |
| 425 | + | .bind(user_id) | |
| 426 | + | .bind(sub_id) | |
| 427 | + | .bind(format!("cus_{sub_id}")) | |
| 428 | + | .execute(&h.db) | |
| 429 | + | .await | |
| 430 | + | .unwrap(); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[tokio::test] | |
| 434 | + | async fn fan_plus_cancel_sets_cancel_at_period_end() { | |
| 435 | + | let mut h = TestHarness::with_mocks().await; | |
| 436 | + | let user_id = h.signup("cancuser", "canc@example.com", "password123").await; | |
| 437 | + | seed_active_fan_plus(&h, user_id, "sub_cancel_1").await; | |
| 438 | + | ||
| 439 | + | h.client.get("/dashboard").await; // prime CSRF | |
| 440 | + | let csrf = h.client.csrf_token().expect("csrf").to_string(); | |
| 441 | + | let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await; | |
| 442 | + | assert!(resp.status.is_redirection(), "status: {} body: {}", resp.status, resp.text); | |
| 443 | + | ||
| 444 | + | let pending: bool = sqlx::query_scalar( | |
| 445 | + | "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1", | |
| 446 | + | ) | |
| 447 | + | .bind(user_id) | |
| 448 | + | .fetch_one(&h.db) | |
| 449 | + | .await | |
| 450 | + | .unwrap(); | |
| 451 | + | assert!(pending); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[tokio::test] | |
| 455 | + | async fn fan_plus_resume_clears_cancel_flag() { | |
| 456 | + | let mut h = TestHarness::with_mocks().await; | |
| 457 | + | let user_id = h.signup("resuuser", "resu@example.com", "password123").await; | |
| 458 | + | sqlx::query( | |
| 459 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 460 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end) \ | |
| 461 | + | VALUES ($1, 'sub_resume_1', 'cus_resume_1', 'active', TRUE)", | |
| 462 | + | ) | |
| 463 | + | .bind(user_id) | |
| 464 | + | .execute(&h.db) | |
| 465 | + | .await | |
| 466 | + | .unwrap(); | |
| 467 | + | ||
| 468 | + | h.client.get("/dashboard").await; | |
| 469 | + | let csrf = h.client.csrf_token().expect("csrf").to_string(); | |
| 470 | + | let resp = h.client.post_form("/stripe/fan-plus/resume", &format!("_csrf={csrf}")).await; | |
| 471 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 472 | + | ||
| 473 | + | let pending: bool = sqlx::query_scalar( | |
| 474 | + | "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1", | |
| 475 | + | ) | |
| 476 | + | .bind(user_id) | |
| 477 | + | .fetch_one(&h.db) | |
| 478 | + | .await | |
| 479 | + | .unwrap(); | |
| 480 | + | assert!(!pending); | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | #[tokio::test] | |
| 484 | + | async fn fan_plus_cancel_requires_active_subscription() { | |
| 485 | + | let mut h = TestHarness::with_mocks().await; | |
| 486 | + | h.signup("nosub", "nosub@example.com", "password123").await; | |
| 487 | + | ||
| 488 | + | h.client.get("/dashboard").await; | |
| 489 | + | let csrf = h.client.csrf_token().expect("csrf").to_string(); | |
| 490 | + | let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await; | |
| 491 | + | // BadRequest from "No active Fan+ subscription" | |
| 492 | + | assert_eq!(resp.status.as_u16(), 400); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | #[tokio::test] | |
| 496 | + | async fn billing_portal_redirects_to_stripe() { | |
| 497 | + | let mut h = TestHarness::with_mocks().await; | |
| 498 | + | let user_id = h.signup("portaluser", "portal@example.com", "password123").await; | |
| 499 | + | seed_active_fan_plus(&h, user_id, "sub_portal_1").await; | |
| 500 | + | ||
| 501 | + | h.client.get("/dashboard").await; | |
| 502 | + | let csrf = h.client.csrf_token().expect("csrf").to_string(); | |
| 503 | + | let resp = h.client.post_form("/stripe/billing-portal", &format!("_csrf={csrf}")).await; | |
| 504 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 505 | + | let location = resp.header("location").expect("Location header"); | |
| 506 | + | assert!(location.starts_with("https://billing.stripe.test/portal")); | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | #[tokio::test] | |
| 510 | + | async fn dashboard_account_tab_shows_fan_plus_pane_for_subscriber() { | |
| 511 | + | let mut h = TestHarness::new().await; | |
| 512 | + | let user_id = h.signup("paneuser", "pane@example.com", "password123").await; | |
| 513 | + | seed_active_fan_plus(&h, user_id, "sub_pane_1").await; | |
| 514 | + | ||
| 515 | + | let resp = h.client.get("/dashboard/tabs/account").await; | |
| 516 | + | assert_eq!(resp.status, 200); | |
| 517 | + | assert!(resp.text.contains("Fan+ membership")); | |
| 518 | + | assert!(resp.text.contains("Cancel")); | |
| 519 | + | assert!(resp.text.contains("Manage billing")); | |
| 520 | + | assert!(!resp.text.contains("Learn about Fan+")); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | #[tokio::test] | |
| 524 | + | async fn dashboard_account_tab_shows_resume_when_cancel_pending() { | |
| 525 | + | let mut h = TestHarness::new().await; | |
| 526 | + | let user_id = h.signup("pendinguser", "pending@example.com", "password123").await; | |
| 527 | + | sqlx::query( | |
| 528 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 529 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end, current_period_end) \ | |
| 530 | + | VALUES ($1, 'sub_pending_1', 'cus_pending_1', 'active', TRUE, NOW() + interval '15 days')", | |
| 531 | + | ) | |
| 532 | + | .bind(user_id) | |
| 533 | + | .execute(&h.db) | |
| 534 | + | .await | |
| 535 | + | .unwrap(); | |
| 536 | + | ||
| 537 | + | let resp = h.client.get("/dashboard/tabs/account").await; | |
| 538 | + | assert_eq!(resp.status, 200); | |
| 539 | + | assert!(resp.text.contains("cancellation scheduled")); | |
| 540 | + | assert!(resp.text.contains("Resume")); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | #[tokio::test] | |
| 544 | + | async fn dashboard_account_tab_shows_upsell_when_not_subscribed() { | |
| 545 | + | let mut h = TestHarness::new().await; | |
| 546 | + | h.signup("notsub", "notsub@example.com", "password123").await; | |
| 547 | + | ||
| 548 | + | let resp = h.client.get("/dashboard/tabs/account").await; | |
| 549 | + | assert_eq!(resp.status, 200); | |
| 550 | + | assert!(resp.text.contains("Learn about Fan+")); | |
| 551 | + | assert!(!resp.text.contains("Manage billing")); | |
| 552 | + | } |