Skip to main content

max / makenotwork

Fan+ self-service: cancel/resume + Stripe billing portal Adds dashboard-driven cancellation that leaves Fan+ active through the current paid period (no proration), a Resume button to undo before period end, and a Manage Billing button that hands off to the Stripe-hosted customer portal for payment-method changes and invoice history. - Migration 114: cancel_at_period_end BOOLEAN on fan_plus_subscriptions - PaymentProvider: set_platform_cancel_at_period_end, create_billing_portal_session - Routes: POST /stripe/fan-plus/{cancel,resume}, POST /stripe/billing-portal - Webhook customer.subscription.updated syncs cancel_at_period_end so cancellations made via the customer portal flow back into the DB - Dashboard account tab: compact Fan+ pane (period end + Cancel/Resume/ Manage billing). Non-subscribers see a one-line link, no upsell. - Tests: 149 lines added to workflows/fan_plus.rs covering the new flows - Template copy: pricing/Fan+ pages use "membership" instead of "subscription" to match the project's membership-vs-subscription rule
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 13:33 UTC
Commit: a63168b54cc4eb8bd2ad42d3b303310ee8629789
Parent: d3ff18a
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.